@quo-systems/quo 0.1.0 → 0.2.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 +9 -6
- package/SPEC.md +235 -137
- package/dist/being/being.d.ts +22 -0
- package/dist/being/being.js +78 -0
- package/dist/being/digest.d.ts +3 -0
- package/dist/being/digest.js +19 -0
- package/dist/being/index.d.ts +5 -0
- package/dist/being/index.js +6 -0
- package/dist/being/silence.d.ts +10 -0
- package/dist/being/silence.js +29 -0
- package/dist/being/types.d.ts +84 -0
- package/dist/being/types.js +16 -0
- package/dist/conformance/assert.d.ts +11 -0
- package/dist/conformance/assert.js +72 -0
- package/dist/conformance/beings.d.ts +114 -0
- package/dist/conformance/beings.js +126 -0
- package/dist/conformance/estate.d.ts +5 -0
- package/dist/conformance/estate.js +316 -0
- package/dist/conformance/index.d.ts +65 -0
- package/dist/conformance/index.js +448 -0
- package/dist/conformance/reach.d.ts +10 -0
- package/dist/conformance/reach.js +72 -0
- package/dist/conformance/store.d.ts +5 -0
- package/dist/conformance/store.js +97 -0
- package/dist/harbor/core.d.ts +46 -0
- package/dist/harbor/core.js +218 -0
- package/dist/harbor/dial.d.ts +8 -0
- package/dist/harbor/dial.js +45 -0
- package/dist/harbor/index.d.ts +6 -0
- package/dist/harbor/index.js +10 -0
- package/dist/harbor/memory.d.ts +24 -0
- package/dist/harbor/memory.js +68 -0
- package/dist/harbor/reach.d.ts +36 -0
- package/dist/harbor/reach.js +166 -0
- package/dist/harbor/store.d.ts +33 -0
- package/dist/harbor/store.js +41 -0
- package/dist/ward/allowance.d.ts +10 -0
- package/dist/ward/allowance.js +60 -0
- package/dist/ward/arithmetic.d.ts +26 -0
- package/dist/ward/arithmetic.js +159 -0
- package/dist/ward/cells.d.ts +3 -0
- package/dist/ward/cells.js +79 -0
- package/dist/ward/door.d.ts +16 -0
- package/dist/ward/door.js +127 -0
- package/dist/ward/ground.d.ts +15 -0
- package/dist/ward/ground.js +1 -0
- package/dist/ward/heirs.d.ts +14 -0
- package/dist/ward/heirs.js +102 -0
- package/dist/ward/index.d.ts +8 -0
- package/dist/ward/index.js +10 -0
- package/dist/ward/owner.d.ts +32 -0
- package/dist/ward/owner.js +116 -0
- package/dist/ward/partition.d.ts +57 -0
- package/dist/ward/partition.js +64 -0
- package/dist/ward/seal.d.ts +50 -0
- package/dist/ward/seal.js +109 -0
- package/dist/ward/stance.d.ts +19 -0
- package/dist/ward/stance.js +259 -0
- package/dist/ward/ward.d.ts +2 -0
- package/dist/ward/ward.js +197 -0
- package/package.json +21 -29
- package/src/being/index.ts +1 -1
- package/src/being/silence.ts +26 -8
- package/src/being/types.ts +13 -3
- package/src/conformance/beings.ts +5 -5
- package/src/conformance/estate.ts +11 -6
- package/src/conformance/index.ts +14 -12
- package/src/harbor/core.ts +56 -6
- package/src/harbor/dial.ts +1 -1
- package/src/harbor/memory.ts +6 -1
- package/src/ward/door.ts +54 -32
- package/src/ward/ground.ts +6 -2
- package/src/ward/heirs.ts +20 -5
- package/src/ward/owner.ts +4 -3
- package/src/ward/partition.ts +10 -0
- package/src/ward/seal.ts +8 -5
- package/src/ward/stance.ts +20 -14
- package/src/ward/ward.ts +9 -9
- package/vectors/framing.json +7 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// The partition is values only, and the ward hands it out through a guard,
|
|
2
|
+
// so a store takes a copy of the values the way a file would: through JSON.
|
|
3
|
+
export const values = (p) => JSON.parse(JSON.stringify(p));
|
|
4
|
+
// The store as a map, one process, nothing kept past it.
|
|
5
|
+
export class MemoryStore {
|
|
6
|
+
rows = new Map();
|
|
7
|
+
reach = new Map();
|
|
8
|
+
async list() {
|
|
9
|
+
return [...this.rows.keys()];
|
|
10
|
+
}
|
|
11
|
+
async load(name) {
|
|
12
|
+
const row = this.rows.get(name);
|
|
13
|
+
return row && { seed: new Uint8Array(row.seed), partition: values(row.partition), record: { ...row.record } };
|
|
14
|
+
}
|
|
15
|
+
async put(name, kept) {
|
|
16
|
+
if (this.rows.has(name))
|
|
17
|
+
throw new Error(`ward ${name} already exists here`);
|
|
18
|
+
this.rows.set(name, { seed: new Uint8Array(kept.seed), partition: values(kept.partition), record: { ...kept.record } });
|
|
19
|
+
}
|
|
20
|
+
async save(name, partition) {
|
|
21
|
+
const row = this.rows.get(name);
|
|
22
|
+
if (row)
|
|
23
|
+
row.partition = values(partition);
|
|
24
|
+
}
|
|
25
|
+
async record(name, record) {
|
|
26
|
+
const row = this.rows.get(name);
|
|
27
|
+
if (row)
|
|
28
|
+
row.record = { ...record };
|
|
29
|
+
}
|
|
30
|
+
async take(name) {
|
|
31
|
+
const kept = await this.load(name);
|
|
32
|
+
this.rows.delete(name);
|
|
33
|
+
return kept;
|
|
34
|
+
}
|
|
35
|
+
async hints() {
|
|
36
|
+
return Object.fromEntries(this.reach);
|
|
37
|
+
}
|
|
38
|
+
async hint(pk, url) {
|
|
39
|
+
this.reach.set(pk, url);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { Wanted } from '../being/types.ts';
|
|
2
|
+
export type Allowance = {
|
|
3
|
+
time: number;
|
|
4
|
+
};
|
|
5
|
+
export declare const DEFAULT: Allowance;
|
|
6
|
+
export declare const CEILING: Allowance;
|
|
7
|
+
export declare function allow(wanted: Wanted | undefined, ceiling?: Allowance, base?: Allowance): Allowance;
|
|
8
|
+
export declare const spent: (a: Allowance) => boolean;
|
|
9
|
+
export declare const LATE: unique symbol;
|
|
10
|
+
export declare function within<T>(ms: number, work: Promise<T>, rang?: () => void): Promise<T | typeof LATE>;
|
|
@@ -0,0 +1,60 @@
|
|
|
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
|
+
// How wide the default is, and how wide she may ask for, are the ward's own —
|
|
15
|
+
// wider is more patient, and no peer can tell the difference except by being
|
|
16
|
+
// refused. A being who never learns the third parameter exists writes correct
|
|
17
|
+
// code forever under these. They are two numbers because the third argument
|
|
18
|
+
// goes both ways: less for the ask she wants back quickly, more for the one
|
|
19
|
+
// she knows is slow.
|
|
20
|
+
export const DEFAULT = { time: 30_000 };
|
|
21
|
+
export const CEILING = { time: 300_000 };
|
|
22
|
+
// What she asked for, held to what her ward allows. A being cannot mint budget
|
|
23
|
+
// nobody granted her: asking for a minute where the ward gives thirty seconds
|
|
24
|
+
// is thirty seconds, silently, because the ceiling is not hers to know.
|
|
25
|
+
// Asking for nothing at all is the default, which is the whole point.
|
|
26
|
+
export function allow(wanted, ceiling = CEILING, base = DEFAULT) {
|
|
27
|
+
const whole = (n, fallback) => (typeof n === 'number' && Number.isFinite(n) && n > 0 ? Math.floor(n) : fallback);
|
|
28
|
+
return { time: Math.min(whole(wanted?.time, base.time), ceiling.time) };
|
|
29
|
+
}
|
|
30
|
+
// Whether a budget has anything left to spend. A door reads this on arrival.
|
|
31
|
+
export const spent = (a) => !(a.time > 0);
|
|
32
|
+
// What a wait that ran out comes back as. Its own value, held by nobody
|
|
33
|
+
// outside the ward, so no answer from any door can be mistaken for it.
|
|
34
|
+
export const LATE = Symbol('late');
|
|
35
|
+
// A wait with an end. This is the one thing the ward times, and it covers the
|
|
36
|
+
// whole of an ask, not only the wire: a relation that comes back round is
|
|
37
|
+
// stopped at its own lane, before a single byte is sealed, and a bound that
|
|
38
|
+
// only watched the wire would never see it. A timer is the only way to stop
|
|
39
|
+
// waiting and every language that runs Quo has one; no runtime is named here.
|
|
40
|
+
//
|
|
41
|
+
// `rang` is called the moment the bell rings, before anyone waiting on the
|
|
42
|
+
// wait is told: whoever holds work that has not started yet reads it and does
|
|
43
|
+
// not start it. What comes back late is not read, and what has not left yet
|
|
44
|
+
// does not leave.
|
|
45
|
+
export async function within(ms, work, rang) {
|
|
46
|
+
let timer;
|
|
47
|
+
const bell = new Promise((ring) => {
|
|
48
|
+
timer = setTimeout(() => {
|
|
49
|
+
rang?.();
|
|
50
|
+
ring(LATE);
|
|
51
|
+
}, ms);
|
|
52
|
+
});
|
|
53
|
+
try {
|
|
54
|
+
return await Promise.race([work, bell]);
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
clearTimeout(timer);
|
|
58
|
+
void work.then(() => { }, () => { }); // whatever it was, it is nobody's answer now
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export declare const KEY = 32;
|
|
2
|
+
export declare const SIGNATURE = 64;
|
|
3
|
+
export declare const NONCE = 12;
|
|
4
|
+
export declare const TAG = 16;
|
|
5
|
+
export declare function hex(bytes: Uint8Array | ArrayBuffer): string;
|
|
6
|
+
export declare function unhex(text: string): Uint8Array;
|
|
7
|
+
export declare function concat(parts: Uint8Array[]): Uint8Array;
|
|
8
|
+
export declare function sameBytes(a: Uint8Array, b: Uint8Array): boolean;
|
|
9
|
+
export declare const smallOrder: (pk: Uint8Array) => boolean;
|
|
10
|
+
export declare function sha256(...parts: Uint8Array[]): Promise<Uint8Array>;
|
|
11
|
+
export type Pair = {
|
|
12
|
+
secret: Uint8Array;
|
|
13
|
+
pk: Uint8Array;
|
|
14
|
+
};
|
|
15
|
+
export declare function signingPair(seed: Uint8Array): Promise<Pair>;
|
|
16
|
+
export declare function sealingPair(seed: Uint8Array): Promise<Pair>;
|
|
17
|
+
export declare function sign(message: Uint8Array, secret: Uint8Array): Promise<Uint8Array>;
|
|
18
|
+
export declare function verify(message: Uint8Array, signature: Uint8Array, pk: Uint8Array): Promise<boolean>;
|
|
19
|
+
export declare function agree(secret: Uint8Array, peerPk: Uint8Array): Promise<Uint8Array>;
|
|
20
|
+
export declare function encrypt(shared: Uint8Array, plaintext: Uint8Array, aad: Uint8Array): Promise<Uint8Array>;
|
|
21
|
+
export declare function decrypt(shared: Uint8Array, ciphertext: Uint8Array, aad: Uint8Array): Promise<Uint8Array>;
|
|
22
|
+
export declare function box(inside: Uint8Array, padlock: Uint8Array, seed: Uint8Array): Promise<{
|
|
23
|
+
bytes: Uint8Array;
|
|
24
|
+
ephemeral: Pair;
|
|
25
|
+
}>;
|
|
26
|
+
export declare function unbox(bytes: Uint8Array, padlockSecret: Uint8Array): Promise<Uint8Array>;
|
|
@@ -0,0 +1,159 @@
|
|
|
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 = () => {
|
|
17
|
+
const s = globalThis.crypto?.subtle;
|
|
18
|
+
if (!s)
|
|
19
|
+
throw new Error('this terrain has no crypto.subtle: a ward needs a secure context');
|
|
20
|
+
return s;
|
|
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
|
+
// A 32-byte secret plus a fixed prefix is the whole PKCS#8 wrapping for both curves.
|
|
29
|
+
const ED_SECRET = unhex('302e020100300506032b657004220420');
|
|
30
|
+
const X_SECRET = unhex('302e020100300506032b656e04220420');
|
|
31
|
+
const ED = { name: 'Ed25519' };
|
|
32
|
+
const X = { name: 'X25519' };
|
|
33
|
+
const HEX = Array.from({ length: 256 }, (_, at) => at.toString(16).padStart(2, '0'));
|
|
34
|
+
export function hex(bytes) {
|
|
35
|
+
let out = '';
|
|
36
|
+
for (const byte of new Uint8Array(bytes))
|
|
37
|
+
out += HEX[byte];
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
export function unhex(text) {
|
|
41
|
+
const out = new Uint8Array(text.length / 2);
|
|
42
|
+
for (let at = 0; at < out.length; at += 1)
|
|
43
|
+
out[at] = parseInt(text.slice(at * 2, at * 2 + 2), 16);
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
export function concat(parts) {
|
|
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, b) {
|
|
56
|
+
if (a.length !== b.length)
|
|
57
|
+
return false;
|
|
58
|
+
let diff = 0;
|
|
59
|
+
for (let at = 0; at < a.length; at += 1)
|
|
60
|
+
diff |= a[at] ^ b[at];
|
|
61
|
+
return diff === 0;
|
|
62
|
+
}
|
|
63
|
+
// The eight small-order points. A public key among them verifies nothing.
|
|
64
|
+
const SMALL_ORDER = [
|
|
65
|
+
'0100000000000000000000000000000000000000000000000000000000000000',
|
|
66
|
+
'ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f',
|
|
67
|
+
'0000000000000000000000000000000000000000000000000000000000000000',
|
|
68
|
+
'0000000000000000000000000000000000000000000000000000000000000080',
|
|
69
|
+
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05',
|
|
70
|
+
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a',
|
|
71
|
+
'26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85',
|
|
72
|
+
'c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa',
|
|
73
|
+
].map(unhex);
|
|
74
|
+
export const smallOrder = (pk) => SMALL_ORDER.some((p) => sameBytes(p, pk));
|
|
75
|
+
const key32 = (value, what) => {
|
|
76
|
+
if (!(value instanceof Uint8Array) || value.length !== KEY)
|
|
77
|
+
throw new Error(`${what} is not a 32-byte key`);
|
|
78
|
+
return value;
|
|
79
|
+
};
|
|
80
|
+
const pkcs8 = (prefix, value, what) => concat([prefix, key32(value, what)]);
|
|
81
|
+
const secretKey = (alg, prefix, value, what, uses) => subtle().importKey('pkcs8', pkcs8(prefix, value, what), alg, true, uses);
|
|
82
|
+
const publicKey = (alg, value, what, uses) => subtle().importKey('raw', key32(value, what), alg, true, uses);
|
|
83
|
+
// Subtle exports the public half of a private key only through a JWK, where `x` is the 32 raw bytes in base64url.
|
|
84
|
+
async function rawPublic(secret) {
|
|
85
|
+
const jwk = await subtle().exportKey('jwk', secret);
|
|
86
|
+
const binary = atob(jwk.x.replaceAll('-', '+').replaceAll('_', '/'));
|
|
87
|
+
const out = new Uint8Array(binary.length);
|
|
88
|
+
for (let at = 0; at < binary.length; at += 1)
|
|
89
|
+
out[at] = binary.charCodeAt(at);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
export async function sha256(...parts) {
|
|
93
|
+
return new Uint8Array(await subtle().digest('SHA-256', concat(parts)));
|
|
94
|
+
}
|
|
95
|
+
export async function signingPair(seed) {
|
|
96
|
+
const secret = await secretKey(ED, ED_SECRET, seed, 'seed', ['sign']);
|
|
97
|
+
return { secret: Uint8Array.from(seed), pk: await rawPublic(secret) };
|
|
98
|
+
}
|
|
99
|
+
export async function sealingPair(seed) {
|
|
100
|
+
const secret = await secretKey(X, X_SECRET, seed, 'seed', ['deriveBits']);
|
|
101
|
+
return { secret: Uint8Array.from(seed), pk: await rawPublic(secret) };
|
|
102
|
+
}
|
|
103
|
+
export async function sign(message, secret) {
|
|
104
|
+
const key = await secretKey(ED, ED_SECRET, secret, 'secret', ['sign']);
|
|
105
|
+
return new Uint8Array(await subtle().sign(ED, key, message));
|
|
106
|
+
}
|
|
107
|
+
export async function verify(message, signature, pk) {
|
|
108
|
+
if (!(signature instanceof Uint8Array) || signature.length !== SIGNATURE)
|
|
109
|
+
return false;
|
|
110
|
+
if (!(pk instanceof Uint8Array) || pk.length !== KEY || smallOrder(pk))
|
|
111
|
+
return false;
|
|
112
|
+
try {
|
|
113
|
+
return await subtle().verify(ED, await publicKey(ED, pk, 'pk', ['verify']), signature, message);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
export async function agree(secret, peerPk) {
|
|
120
|
+
const key = await secretKey(X, X_SECRET, secret, 'secret', ['deriveBits']);
|
|
121
|
+
const peer = await publicKey(X, peerPk, 'padlock', []);
|
|
122
|
+
const shared = new Uint8Array(await subtle().deriveBits({ name: X.name, public: peer }, key, KEY * 8));
|
|
123
|
+
if (shared.every((b) => b === 0))
|
|
124
|
+
throw new Error('dead agreement'); // a padlock that was not a real key
|
|
125
|
+
return shared;
|
|
126
|
+
}
|
|
127
|
+
// One HKDF-SHA-256 yields the AES key and the nonce together. The nonce needs
|
|
128
|
+
// no randomness of its own: the key it pairs with is fresh on every message.
|
|
129
|
+
async function cipherKey(shared, use) {
|
|
130
|
+
const material = await subtle().importKey('raw', shared, 'HKDF', false, ['deriveBits']);
|
|
131
|
+
const out = new Uint8Array(await subtle().deriveBits({ name: 'HKDF', hash: 'SHA-256', salt: SEAL_SALT, info: SEAL_INFO }, material, (KEY + NONCE) * 8));
|
|
132
|
+
return { key: await subtle().importKey('raw', out.subarray(0, KEY), 'AES-GCM', false, [use]), nonce: out.subarray(KEY) };
|
|
133
|
+
}
|
|
134
|
+
// The additional authenticated data is the ephemeral public key: the one thing outside the seal, bound to it.
|
|
135
|
+
export async function encrypt(shared, plaintext, aad) {
|
|
136
|
+
const { key, nonce } = await cipherKey(shared, 'encrypt');
|
|
137
|
+
return new Uint8Array(await subtle().encrypt({ name: 'AES-GCM', iv: nonce, additionalData: key32(aad, 'aad'), tagLength: TAG * 8 }, key, plaintext));
|
|
138
|
+
}
|
|
139
|
+
export async function decrypt(shared, ciphertext, aad) {
|
|
140
|
+
if (ciphertext.length < TAG)
|
|
141
|
+
throw new Error('short input');
|
|
142
|
+
const { key, nonce } = await cipherKey(shared, 'decrypt');
|
|
143
|
+
return new Uint8Array(await subtle().decrypt({ name: 'AES-GCM', iv: nonce, additionalData: key32(aad, 'aad'), tagLength: TAG * 8 }, key, ciphertext));
|
|
144
|
+
}
|
|
145
|
+
// A box: an ephemeral X25519 pk outside, one ciphertext sealed to the
|
|
146
|
+
// padlock. The answer to a box is a box sealed to that ephemeral pk, so the
|
|
147
|
+
// sender keeps the ephemeral secret until the answer comes.
|
|
148
|
+
export async function box(inside, padlock, seed) {
|
|
149
|
+
const ephemeral = await sealingPair(seed);
|
|
150
|
+
const shared = await agree(ephemeral.secret, padlock);
|
|
151
|
+
return { bytes: concat([ephemeral.pk, await encrypt(shared, inside, ephemeral.pk)]), ephemeral };
|
|
152
|
+
}
|
|
153
|
+
export async function unbox(bytes, padlockSecret) {
|
|
154
|
+
if (bytes.length <= KEY)
|
|
155
|
+
throw new Error('short input');
|
|
156
|
+
const ephemeralPk = bytes.subarray(0, KEY);
|
|
157
|
+
const shared = await agree(padlockSecret, ephemeralPk);
|
|
158
|
+
return decrypt(shared, bytes.subarray(KEY), ephemeralPk);
|
|
159
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// I-JSON, all the way down. A number that JSON cannot write is not a number
|
|
2
|
+
// a harbor can keep, and a key on the prototype is not a key she wrote.
|
|
3
|
+
function fault(v, path, seen) {
|
|
4
|
+
if (v === null || typeof v === 'boolean' || typeof v === 'string')
|
|
5
|
+
return null;
|
|
6
|
+
if (typeof v === 'number')
|
|
7
|
+
return Number.isFinite(v) ? null : `${path} is ${String(v)}, which no harbor can write down`;
|
|
8
|
+
if (typeof v !== 'object')
|
|
9
|
+
return `${path} is a ${typeof v}, which is not a value`;
|
|
10
|
+
if (seen.has(v))
|
|
11
|
+
return `${path} refers back to itself`;
|
|
12
|
+
seen.add(v);
|
|
13
|
+
try {
|
|
14
|
+
if (Array.isArray(v)) {
|
|
15
|
+
for (let i = 0; i < v.length; i += 1) {
|
|
16
|
+
const f = fault(v[i], `${path}[${i}]`, seen);
|
|
17
|
+
if (f)
|
|
18
|
+
return f;
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
const proto = Object.getPrototypeOf(v);
|
|
23
|
+
if (proto !== Object.prototype && proto !== null)
|
|
24
|
+
return `${path} is a ${(v).constructor?.name ?? 'object'}, which is not a value`;
|
|
25
|
+
for (const k of Object.keys(v)) {
|
|
26
|
+
const f = fault(v[k], `${path}.${k}`, seen);
|
|
27
|
+
if (f)
|
|
28
|
+
return f;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
seen.delete(v);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export const cellFault = (v, path) => fault(v, path, new Set());
|
|
37
|
+
// The guard is one proxy at the root and one for every container read through
|
|
38
|
+
// it, so a write nested three deep is refused the same way a write at the top
|
|
39
|
+
// is. Wrappers are remembered, so reading the same array twice is the same
|
|
40
|
+
// object twice and a being may still compare what she holds.
|
|
41
|
+
const wrapped = new WeakMap();
|
|
42
|
+
const guards = new WeakSet();
|
|
43
|
+
function guard(target, path) {
|
|
44
|
+
if (guards.has(target))
|
|
45
|
+
return target;
|
|
46
|
+
const had = wrapped.get(target);
|
|
47
|
+
if (had)
|
|
48
|
+
return had;
|
|
49
|
+
const p = new Proxy(target, {
|
|
50
|
+
get(t, k, r) {
|
|
51
|
+
const v = Reflect.get(t, k, r);
|
|
52
|
+
// A container reached through her cells is part of her cells.
|
|
53
|
+
return v !== null && typeof v === 'object' && !ArrayBuffer.isView(v) ? guard(v, `${path}.${String(k)}`) : v;
|
|
54
|
+
},
|
|
55
|
+
set(t, k, v, r) {
|
|
56
|
+
if (typeof k === 'string') {
|
|
57
|
+
const f = cellFault(v, `${path}.${k}`);
|
|
58
|
+
if (f)
|
|
59
|
+
throw new TypeError(`cells hold values: ${f}`);
|
|
60
|
+
}
|
|
61
|
+
return Reflect.set(t, k, v, r);
|
|
62
|
+
},
|
|
63
|
+
defineProperty(t, k, d) {
|
|
64
|
+
if (typeof k === 'string' && 'value' in d) {
|
|
65
|
+
const f = cellFault(d.value, `${path}.${k}`);
|
|
66
|
+
if (f)
|
|
67
|
+
throw new TypeError(`cells hold values: ${f}`);
|
|
68
|
+
}
|
|
69
|
+
return Reflect.defineProperty(t, k, d);
|
|
70
|
+
},
|
|
71
|
+
});
|
|
72
|
+
wrapped.set(target, p);
|
|
73
|
+
guards.add(p);
|
|
74
|
+
return p;
|
|
75
|
+
}
|
|
76
|
+
// Her cells, guarded. Called once per being at boot; the guarded object is
|
|
77
|
+
// what goes into the partition and what the stance hands her, so there is no
|
|
78
|
+
// second door onto the same cells.
|
|
79
|
+
export const guardCells = (cells) => guard(cells, 'cells');
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Asker, BeingLike, JsonObject } from '../being/types.ts';
|
|
2
|
+
import type { Heirs } from './heirs.ts';
|
|
3
|
+
import { type ReplyPayload, type WardKey } from './seal.ts';
|
|
4
|
+
export type Door = {
|
|
5
|
+
key: string;
|
|
6
|
+
being: BeingLike;
|
|
7
|
+
cells: {
|
|
8
|
+
occupants: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
export type Judged = {
|
|
12
|
+
bytes: Uint8Array;
|
|
13
|
+
heard: boolean;
|
|
14
|
+
};
|
|
15
|
+
export declare function arrive(door: Door, asker: Asker, method: string | undefined, args: JsonObject, bound: boolean): Promise<ReplyPayload>;
|
|
16
|
+
export declare function makeDoor(key: WardKey, heirs: Heirs, doors: Map<string, Door>, publicKey: () => string | null, random: (n: number) => Uint8Array): (bytes: Uint8Array) => Promise<Judged>;
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
// Judgment at the door. Sealed bytes in, sealed bytes out, and one bit for
|
|
3
|
+
// the harbor: whether a key this door holds spoke. A stranger, bytes the
|
|
4
|
+
// door cannot admit, meets one silence whatever the case, so that it learns
|
|
5
|
+
// nothing, not even which case it hit. A key the door has bound hears the
|
|
6
|
+
// ward's word for what happened, sealed to its own lid, because it has
|
|
7
|
+
// already proven who it is and a reason to it is an oracle to nobody. What
|
|
8
|
+
// passes is named and dispatched to her answer, and her digest for that
|
|
9
|
+
// asker rides back with the object.
|
|
10
|
+
import { isSilence, isWord } from '../being/silence.js';
|
|
11
|
+
import { digest } from '../being/digest.js';
|
|
12
|
+
import { at } from './partition.js';
|
|
13
|
+
import { openAsk, sealReply, verifyAsk } from './seal.js';
|
|
14
|
+
import { spent } from './allowance.js';
|
|
15
|
+
import { KEY, sealingPair } from './arithmetic.js';
|
|
16
|
+
const SILENCE = { silence: true };
|
|
17
|
+
const said = (quo) => ({ quo });
|
|
18
|
+
// One arrival at one being, already named. Catches every throw. `bound` says
|
|
19
|
+
// whether the asker is a key this door holds: she hears `threw`; a stranger
|
|
20
|
+
// at the public being hears silence, because her insides are hers.
|
|
21
|
+
export async function arrive(door, asker, method, args, bound) {
|
|
22
|
+
const threw = bound ? said('threw') : SILENCE;
|
|
23
|
+
let out;
|
|
24
|
+
try {
|
|
25
|
+
out = await door.being.answer(asker, method, args);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return threw; // she threw where she was asked. there is no answer.
|
|
29
|
+
}
|
|
30
|
+
// Nothing at all is not an answer either: a method that forgot to return
|
|
31
|
+
// has said nothing, and nothing is silence, never a value that JSON drops
|
|
32
|
+
// on the way out and the far side reads back as a fourth word.
|
|
33
|
+
if (out === undefined || isSilence(out))
|
|
34
|
+
return SILENCE;
|
|
35
|
+
if (isWord(out))
|
|
36
|
+
return threw; // a word is the ward's to say, never hers
|
|
37
|
+
if (method === undefined)
|
|
38
|
+
return { object: out, seen: null };
|
|
39
|
+
// The digest rides along, it is not the answer. She has already answered:
|
|
40
|
+
// a describe that will not run costs the digest, and nothing else.
|
|
41
|
+
return { object: out, seen: await seen(door, asker) };
|
|
42
|
+
}
|
|
43
|
+
async function seen(door, asker) {
|
|
44
|
+
try {
|
|
45
|
+
const bp = await door.being.answer(asker);
|
|
46
|
+
return isSilence(bp) || isWord(bp) ? null : await digest(bp);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
export function makeDoor(key, heirs, doors, publicKey, random) {
|
|
53
|
+
const judge = async (bytes) => {
|
|
54
|
+
const a = await openAsk(bytes, key.padlock);
|
|
55
|
+
if (!a)
|
|
56
|
+
return null; // D1. it did not open. there is nobody to answer.
|
|
57
|
+
const { to, payload, ephemeralPk } = a;
|
|
58
|
+
const refuse = () => ({ reply: SILENCE, ephemeralPk, heard: false });
|
|
59
|
+
// D2, the rest of it. The allowance is read before anything is done under
|
|
60
|
+
// it, and hops at zero is refused so that a relay chain invented later
|
|
61
|
+
// meets doors that already stop it. Nothing sets hops.
|
|
62
|
+
if (spent({ time: payload.time }) || payload.hops === 0)
|
|
63
|
+
return refuse();
|
|
64
|
+
const args = payload.args ?? {};
|
|
65
|
+
if (to === null) {
|
|
66
|
+
// For nobody: the public being, or D3 nobody home; D4 the signature
|
|
67
|
+
// fails under the key the payload names. She is asked by strangers,
|
|
68
|
+
// and a stranger hears silence for whatever she then does.
|
|
69
|
+
const pk = publicKey();
|
|
70
|
+
const pub = pk !== null ? doors.get(pk) : undefined;
|
|
71
|
+
if (!pub || !(await verifyAsk(a, payload.by)))
|
|
72
|
+
return refuse();
|
|
73
|
+
return { reply: await arrive(pub, {}, payload.method, args, false), ephemeralPk, heard: true };
|
|
74
|
+
}
|
|
75
|
+
const h = heirs.admits(to, payload.by);
|
|
76
|
+
if (!h) {
|
|
77
|
+
// D5 a heir not held, D6 a key not admitted. One more case: the heir
|
|
78
|
+
// was held and the id removed. Its last keys are kept so that their
|
|
79
|
+
// holder, and only their holder, D7 checked, hears that she is gone.
|
|
80
|
+
if (!heirs.gone(to, payload.by) || !(await verifyAsk(a, payload.by)))
|
|
81
|
+
return refuse();
|
|
82
|
+
return { reply: said('removed'), ephemeralPk, heard: true };
|
|
83
|
+
}
|
|
84
|
+
if (!(await verifyAsk(a, payload.by)))
|
|
85
|
+
return refuse(); // D7, under an admitted key
|
|
86
|
+
// From here the door has heard a key it holds. Every answer below is
|
|
87
|
+
// sealed to that key's lid; nothing below is a stranger's.
|
|
88
|
+
const door = doors.get(h.being);
|
|
89
|
+
if (!door)
|
|
90
|
+
return { reply: said('absent'), ephemeralPk, heard: true }; // D8. she did not come back this run
|
|
91
|
+
if (!at(door.cells.occupants, h.id))
|
|
92
|
+
return { reply: said('removed'), ephemeralPk, heard: true }; // D8. the record is gone
|
|
93
|
+
// Once only, and only now: the number is spent after the signature, so a
|
|
94
|
+
// stranger cannot burn a number she could not sign for, and together with
|
|
95
|
+
// the keys, so the same bytes twice rotate nothing and refusal writes nothing.
|
|
96
|
+
const honoured = heirs.honour(h, payload.by, payload.next, payload.seq); // D9, D10
|
|
97
|
+
if (honoured !== true)
|
|
98
|
+
return { reply: said(honoured), ephemeralPk, heard: true };
|
|
99
|
+
return { reply: await arrive(door, { id: h.id }, payload.method, args, true), ephemeralPk, heard: true };
|
|
100
|
+
};
|
|
101
|
+
// The door itself. Always answers bytes. When the ask did not open, the
|
|
102
|
+
// reply is sealed to the ephemeral pk on its lid, which is all a stranger
|
|
103
|
+
// holds, and says silence.
|
|
104
|
+
//
|
|
105
|
+
// A lid that is not a key -- a small-order point, of which the two curves
|
|
106
|
+
// have several each -- makes a dead agreement, and the seal refuses it. The
|
|
107
|
+
// door never throws: that reply is noise, sealed to a key nobody holds.
|
|
108
|
+
return async function door(bytes) {
|
|
109
|
+
const out = await judge(bytes);
|
|
110
|
+
const reply = out?.reply ?? SILENCE;
|
|
111
|
+
const heard = out?.heard ?? false;
|
|
112
|
+
try {
|
|
113
|
+
return { bytes: await sealReply(reply, out?.ephemeralPk ?? lid(bytes, random), key.sign, random(32)), heard };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return { bytes: await sealReply(reply, (await sealingPair(random(32))).pk, key.sign, random(32)), heard };
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
// The ephemeral pk on an ask that would not open, if the bytes are long
|
|
121
|
+
// enough to carry one. Otherwise a fresh key nobody holds: the reply is
|
|
122
|
+
// bytes, and it is noise.
|
|
123
|
+
function lid(bytes, random) {
|
|
124
|
+
if (bytes instanceof Uint8Array && bytes.length >= KEY)
|
|
125
|
+
return bytes.subarray(0, KEY);
|
|
126
|
+
return random(KEY);
|
|
127
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Stance, BeingLike } from '../being/types.ts';
|
|
2
|
+
export type Ground = {
|
|
3
|
+
seed: string | Uint8Array;
|
|
4
|
+
memory: Record<string, unknown>;
|
|
5
|
+
instantiate(className: string, stance: Stance): BeingLike | null;
|
|
6
|
+
carry(pk: string, bytes: Uint8Array): Promise<Uint8Array | undefined>;
|
|
7
|
+
random(n: number): Uint8Array;
|
|
8
|
+
};
|
|
9
|
+
export type WardPointers = {
|
|
10
|
+
door(bytes: Uint8Array): Promise<{
|
|
11
|
+
bytes: Uint8Array;
|
|
12
|
+
heard: boolean;
|
|
13
|
+
}>;
|
|
14
|
+
ask(method?: string, args?: Record<string, unknown>): Promise<unknown>;
|
|
15
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DoorWord } from '../being/types.ts';
|
|
2
|
+
import { type Heir, type Partition } from './partition.ts';
|
|
3
|
+
export declare class Heirs {
|
|
4
|
+
#private;
|
|
5
|
+
constructor(p: Partition);
|
|
6
|
+
open(heir: string, being: string, id: string): void;
|
|
7
|
+
close(heir: string): void;
|
|
8
|
+
gone(heir: string, by: string): boolean;
|
|
9
|
+
get(heir: string): Heir | undefined;
|
|
10
|
+
admits(heir: string, by: string): Heir | null;
|
|
11
|
+
spend(h: Heir, seq: number): boolean;
|
|
12
|
+
honour(h: Heir, by: string, next: string | null, seq: number): true | DoorWord;
|
|
13
|
+
settle(h: Heir, by: string, next: string | null): boolean;
|
|
14
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { GONE } from './partition.js';
|
|
2
|
+
// How wide the span is, is the ward's own — wider is more forgiving of a
|
|
3
|
+
// rough road, and no peer can tell the difference except by being refused.
|
|
4
|
+
const SPAN = 64;
|
|
5
|
+
export class Heirs {
|
|
6
|
+
#p;
|
|
7
|
+
constructor(p) {
|
|
8
|
+
this.#p = p;
|
|
9
|
+
}
|
|
10
|
+
open(heir, being, id) {
|
|
11
|
+
this.#p.heirs[heir] = { being, id, current: heir, announced: null, fresh: true, mark: 0, spent: [] };
|
|
12
|
+
}
|
|
13
|
+
// The id was removed. The heir is forgotten, and its last keys are kept
|
|
14
|
+
// apart, bounded, so that whoever still holds them hears `removed` at the
|
|
15
|
+
// door, once they have signed as themselves, and nobody else hears a thing.
|
|
16
|
+
close(heir) {
|
|
17
|
+
const h = this.#p.heirs[heir];
|
|
18
|
+
if (!h)
|
|
19
|
+
return;
|
|
20
|
+
delete this.#p.heirs[heir];
|
|
21
|
+
this.#p.gone[heir] = { current: h.current, announced: h.announced };
|
|
22
|
+
const keys = Object.keys(this.#p.gone);
|
|
23
|
+
for (const old of keys.slice(0, Math.max(0, keys.length - GONE)))
|
|
24
|
+
delete this.#p.gone[old];
|
|
25
|
+
}
|
|
26
|
+
// May `by` speak for a relation she removed? True only for the keys the
|
|
27
|
+
// door held when the id went, which nobody but their holder has.
|
|
28
|
+
gone(heir, by) {
|
|
29
|
+
const g = this.#p.gone[heir];
|
|
30
|
+
return !!g && (by === g.current || by === g.announced);
|
|
31
|
+
}
|
|
32
|
+
get(heir) {
|
|
33
|
+
return this.#p.heirs[heir];
|
|
34
|
+
}
|
|
35
|
+
// May `by` speak for this heir? Returns the record if so, null if not.
|
|
36
|
+
// Does not write: the caller verifies the signature first, then settles.
|
|
37
|
+
admits(heir, by) {
|
|
38
|
+
const h = this.#p.heirs[heir];
|
|
39
|
+
if (!h)
|
|
40
|
+
return null;
|
|
41
|
+
if (h.fresh)
|
|
42
|
+
return by === h.current ? h : null;
|
|
43
|
+
return by === h.current || by === h.announced ? h : null;
|
|
44
|
+
}
|
|
45
|
+
// Once only. A number above the mark is honoured and moves it; a number
|
|
46
|
+
// inside the span is honoured once and never again; a number at or below
|
|
47
|
+
// the span is refused, because a door that remembered every number ever
|
|
48
|
+
// seen would be a door with unbounded memory. Counting starts where
|
|
49
|
+
// strangers must agree: the first legal number is one.
|
|
50
|
+
//
|
|
51
|
+
// The number rides inside the signed body, so bytes caught on the road
|
|
52
|
+
// carry the number they were sent under and are refused as themselves. A
|
|
53
|
+
// caller who means to ask again asks again, under the next number, and is
|
|
54
|
+
// heard: retry and fire-and-forget stay hers to build. Only the accident
|
|
55
|
+
// and the interception are refused.
|
|
56
|
+
//
|
|
57
|
+
// Writes, so the caller settles it only once the signature has checked out.
|
|
58
|
+
// The same bytes twice are one honoured call and one silence.
|
|
59
|
+
spend(h, seq) {
|
|
60
|
+
if (!Number.isSafeInteger(seq) || seq < 1)
|
|
61
|
+
return false;
|
|
62
|
+
if (seq > h.mark) {
|
|
63
|
+
if (h.mark > 0)
|
|
64
|
+
h.spent.push(h.mark);
|
|
65
|
+
h.mark = seq;
|
|
66
|
+
h.spent = h.spent.filter((past) => past > seq - SPAN);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
if (seq === h.mark || seq <= h.mark - SPAN || h.spent.includes(seq))
|
|
70
|
+
return false;
|
|
71
|
+
h.spent.push(seq);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
// The signature checked out. Spend the number and settle the keys, or do
|
|
75
|
+
// neither: a call that binds nothing must not burn a number on its way to
|
|
76
|
+
// being refused, or a stranger who cannot be heard would still leave a mark
|
|
77
|
+
// behind her. Every write below this line is one that is going to hold.
|
|
78
|
+
honour(h, by, next, seq) {
|
|
79
|
+
if (h.fresh && next === null)
|
|
80
|
+
return 'unannounced'; // a knock without a key of her own binds nothing
|
|
81
|
+
if (!this.spend(h, seq))
|
|
82
|
+
return 'repeated';
|
|
83
|
+
return this.settle(h, by, next) ? true : 'unannounced';
|
|
84
|
+
}
|
|
85
|
+
// The signature checked out. Settle the keys: a fresh heir rotates at once
|
|
86
|
+
// to what it announced and must announce something; a current key replaces
|
|
87
|
+
// its announcement; an announced key becomes current.
|
|
88
|
+
settle(h, by, next) {
|
|
89
|
+
if (h.fresh) {
|
|
90
|
+
if (next === null)
|
|
91
|
+
return false; // a knock without a key of her own binds nothing
|
|
92
|
+
h.current = next;
|
|
93
|
+
h.announced = null;
|
|
94
|
+
h.fresh = false;
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
if (by === h.announced)
|
|
98
|
+
h.current = by;
|
|
99
|
+
h.announced = next;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
}
|