@githolon/dsl 0.49.0 → 0.51.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/check_package.mjs +46 -0
- package/dart/example/nomos_secure_key_store.dart +82 -0
- package/dart/lib/src/webview_bridge.dart +442 -6
- package/package.json +3 -2
- package/src/check_package_main.ts +194 -0
- package/src/codegen_dart_client.ts +124 -8
- package/src/codegen_proof.ts +1 -1
- package/src/codegen_ts.ts +470 -14
- package/src/compile_package_main.ts +19 -0
- package/src/evolve_preview.ts +466 -0
- package/src/law_check.ts +777 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// NOMOS — Nomos Sovereign: participants act · verify · remember LOCALLY; hosted
|
|
3
|
+
// remotes are replaceable custody/transport, not truth. ⇒ ONE Nomos GitHolon
|
|
4
|
+
// wasm32-wasip1 artifact {kernel · projection · embedded QuickJS engine}.
|
|
5
|
+
// If a file isn't this / hosting this / authoring for this / proving this — it's gone.
|
|
6
|
+
|
|
7
|
+
// `githolon check` launcher — the twin of `compile_package.mjs`: resolve `tsx`
|
|
8
|
+
// (the caller's, else the DSL's own dependency) and run `src/check_package_main.ts`
|
|
9
|
+
// under it (the main dynamically imports the caller's TS domain modules, which
|
|
10
|
+
// needs the tsx ESM loader). Resolution goes through Node's resolver (createRequire),
|
|
11
|
+
// NOT node_modules/.bin paths — hoisted/workspace installs put the bin shims elsewhere.
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
13
|
+
import { readFileSync } from "node:fs";
|
|
14
|
+
import { createRequire } from "node:module";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
17
|
+
|
|
18
|
+
const DSL_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
const MAIN = path.join(DSL_DIR, "src", "check_package_main.ts");
|
|
20
|
+
|
|
21
|
+
function resolvePkgDir(name, fromDir) {
|
|
22
|
+
try {
|
|
23
|
+
const req = createRequire(pathToFileURL(path.join(fromDir, "noop.js")));
|
|
24
|
+
return path.dirname(req.resolve(`${name}/package.json`));
|
|
25
|
+
} catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tsxDir = resolvePkgDir("tsx", process.cwd()) ?? resolvePkgDir("tsx", DSL_DIR);
|
|
31
|
+
if (!tsxDir) {
|
|
32
|
+
console.error("githolon check: tsx not found — reinstall @githolon/dsl (tsx is one of its dependencies)");
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
const tsxPkg = JSON.parse(readFileSync(path.join(tsxDir, "package.json"), "utf8"));
|
|
36
|
+
const tsxBinRel = typeof tsxPkg.bin === "string" ? tsxPkg.bin : tsxPkg.bin?.tsx;
|
|
37
|
+
const tsxCli = path.join(tsxDir, tsxBinRel ?? "dist/cli.mjs");
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
execFileSync(process.execPath, [tsxCli, MAIN, ...process.argv.slice(2)], {
|
|
41
|
+
stdio: "inherit",
|
|
42
|
+
cwd: process.cwd(),
|
|
43
|
+
});
|
|
44
|
+
} catch (e) {
|
|
45
|
+
process.exit(e.status ?? 1);
|
|
46
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// COPY THIS INTO YOUR FLUTTER APP. It is an EXAMPLE, not part of the `nomos_client` library: it depends on
|
|
2
|
+
// `flutter_secure_storage`, which the pure-Dart `nomos_client` package deliberately does NOT depend on (so
|
|
3
|
+
// the protocol logic stays Flutter-free and unit-testable). This is the ~40 lines of app-specific glue for
|
|
4
|
+
// where your device secret lives — the E2E key CUSTODY policy is yours; the protocol plumbing is not.
|
|
5
|
+
//
|
|
6
|
+
// THE SEALING CLIENT (e2e_sealing_client.md): a generated `<Domain>Client(bridge, keyStore: ...)` seals
|
|
7
|
+
// encrypted directive fields before dispatch + decrypts encrypted read fields automatically. The `keyStore`
|
|
8
|
+
// is the ONE thing you supply. `WrappedKeyStore` (in `nomos_client`) is the default: it unwraps this
|
|
9
|
+
// principal's on-chain WrappedKeys with a DEVICE SECRET. This example shows where that device secret lives —
|
|
10
|
+
// the platform keychain / Keystore via `flutter_secure_storage` — so it survives app restarts, stays off the
|
|
11
|
+
// chain, and never leaves the device.
|
|
12
|
+
//
|
|
13
|
+
// WIRING (once per signed-in principal):
|
|
14
|
+
// final store = await SecureDeviceKeyStore.open(bridge: bridge, principal: myUid);
|
|
15
|
+
// final app = NotesClient(bridge, actor: myUid, keyStore: store); // reads decrypt, writes seal
|
|
16
|
+
// final rows = await app.notesByOwner(owner: myUid); // `body` comes back plaintext
|
|
17
|
+
//
|
|
18
|
+
// The OWNER ceremony (grantAndWrap / revokeAndRotate / rotate) uses the SAME device pubkey this store
|
|
19
|
+
// publishes — see `<Domain>Client(bridge, actor: owner).encryption`.
|
|
20
|
+
|
|
21
|
+
import 'dart:convert';
|
|
22
|
+
|
|
23
|
+
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
|
24
|
+
import 'package:nomos_client/nomos_client.dart';
|
|
25
|
+
|
|
26
|
+
/// A [NomosKeyStore] whose device secret is held in the platform keychain (iOS Keychain / Android Keystore)
|
|
27
|
+
/// via `flutter_secure_storage`. It delegates the scope-key unwrap to the framework's [WrappedKeyStore]; the
|
|
28
|
+
/// only thing it adds is DURABLE, PLATFORM-PROTECTED custody of the X25519 device secret.
|
|
29
|
+
class SecureDeviceKeyStore implements NomosKeyStore {
|
|
30
|
+
final WrappedKeyStore _inner;
|
|
31
|
+
|
|
32
|
+
/// This device's X25519 PUBLIC key — publish it (publishEncKey) / hand it to the owner as `publicKey` on
|
|
33
|
+
/// `grantAndWrap` so the scope key can be wrapped TO this device.
|
|
34
|
+
final String devicePublicKey;
|
|
35
|
+
|
|
36
|
+
SecureDeviceKeyStore._(this._inner, this.devicePublicKey);
|
|
37
|
+
|
|
38
|
+
/// Open (or first-time provision) the store for `principal`. On first run it MINTS an X25519 device keypair
|
|
39
|
+
/// in the kernel, persists the SECRET in secure storage, and returns the store; on later runs it reuses it.
|
|
40
|
+
static Future<SecureDeviceKeyStore> open({
|
|
41
|
+
required NomosBridge bridge,
|
|
42
|
+
required String principal,
|
|
43
|
+
FlutterSecureStorage storage = const FlutterSecureStorage(),
|
|
44
|
+
}) async {
|
|
45
|
+
final secretKey = 'nomos.enc.deviceSecret.$principal';
|
|
46
|
+
final publicKey = 'nomos.enc.devicePublic.$principal';
|
|
47
|
+
var secret = await storage.read(key: secretKey);
|
|
48
|
+
var public = await storage.read(key: publicKey);
|
|
49
|
+
if (secret == null || public == null) {
|
|
50
|
+
// FIRST RUN: mint the device keypair (secret stays client-side, never on chain) and persist it.
|
|
51
|
+
final kp = await bridge.cryptoKeygen();
|
|
52
|
+
secret = (kp['secret'] ?? '').toString();
|
|
53
|
+
public = (kp['public'] ?? '').toString();
|
|
54
|
+
await storage.write(key: secretKey, value: secret);
|
|
55
|
+
await storage.write(key: publicKey, value: public);
|
|
56
|
+
}
|
|
57
|
+
final inner = WrappedKeyStore(
|
|
58
|
+
bridge: bridge, principal: principal, deviceSecret: secret);
|
|
59
|
+
return SecureDeviceKeyStore._(inner, public);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@override
|
|
63
|
+
Future<NomosScopeKey?> scopeKey(String scope) => _inner.scopeKey(scope);
|
|
64
|
+
|
|
65
|
+
@override
|
|
66
|
+
Future<List<NomosScopeKey>> readKeys(List<String> scopes) =>
|
|
67
|
+
_inner.readKeys(scopes);
|
|
68
|
+
|
|
69
|
+
/// Convenience: the JSON a `publishEncKey` payload carries (bind the device pubkey to this principal on
|
|
70
|
+
/// chain so an owner's `grantAndWrap` can resolve it without you passing `publicKey` each time).
|
|
71
|
+
Map<String, Object?> publishPayload({required String publishedAt}) => {
|
|
72
|
+
'principal': _inner.principal,
|
|
73
|
+
'encPublicKey': devicePublicKey,
|
|
74
|
+
'publishedAt': publishedAt,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
@override
|
|
78
|
+
String toString() => 'SecureDeviceKeyStore(${jsonEncode({
|
|
79
|
+
'principal': _inner.principal,
|
|
80
|
+
'devicePublicKey': devicePublicKey,
|
|
81
|
+
})})';
|
|
82
|
+
}
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
library;
|
|
16
16
|
|
|
17
17
|
import 'dart:async';
|
|
18
|
+
import 'dart:convert';
|
|
18
19
|
import 'dart:math';
|
|
19
20
|
|
|
20
21
|
import 'errors.dart' show nomosErrorFromInfo;
|
|
@@ -292,6 +293,377 @@ class NomosBbox {
|
|
|
292
293
|
{'minLng': minLng, 'minLat': minLat, 'maxLng': maxLng, 'maxLat': maxLat};
|
|
293
294
|
}
|
|
294
295
|
|
|
296
|
+
// ── E2E SEALING (the sealing client) — the GENERIC key-custody surface. Generated domain clients bake the
|
|
297
|
+
// per-domain map + seal/read helpers, but these types are domain-agnostic, so they live here (in the
|
|
298
|
+
// nomos_client library) exactly like [MissingOfferAuthority] — one definition, re-exported per client. ──
|
|
299
|
+
|
|
300
|
+
/// A held E2E scope key: the base64 symmetric key + the epoch it belongs to (forward-only rotation).
|
|
301
|
+
class NomosScopeKey {
|
|
302
|
+
final String scope;
|
|
303
|
+
final int epoch;
|
|
304
|
+
final String key;
|
|
305
|
+
const NomosScopeKey(
|
|
306
|
+
{required this.scope, required this.epoch, required this.key});
|
|
307
|
+
Map<String, Object?> toJson() => {'scope': scope, 'epoch': epoch, 'key': key};
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/// The app-supplied KEY CUSTODY surface (E2E sealing). Supply one to a generated `<Domain>Client` and its
|
|
311
|
+
/// encrypted directive fields SEAL automatically before dispatch + encrypted read fields DECRYPT
|
|
312
|
+
/// automatically. [WrappedKeyStore] is the default over the on-chain WrappedKeys; an app may implement this
|
|
313
|
+
/// to source keys from secure storage (the key custody policy stays the app's; the protocol plumbing does not).
|
|
314
|
+
abstract class NomosKeyStore {
|
|
315
|
+
/// The CURRENT (highest-epoch) scope key, for SEALING a write. null ⇒ EncryptionKeyUnavailable.
|
|
316
|
+
Future<NomosScopeKey?> scopeKey(String scope);
|
|
317
|
+
|
|
318
|
+
/// EVERY held key for these scopes (all epochs), injected on reads so old ciphertext also decrypts.
|
|
319
|
+
Future<List<NomosScopeKey>> readKeys(List<String> scopes);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/// Thrown when a write must seal an encrypted field but the KeyStore holds no key for its scope — the write
|
|
323
|
+
/// NEVER falls through to a plaintext dispatch (the sid-envelope gate would refuse it anyway; we pre-fire).
|
|
324
|
+
class EncryptionKeyUnavailable implements Exception {
|
|
325
|
+
final String scope;
|
|
326
|
+
final String field;
|
|
327
|
+
String get code => 'nomos.crypto.key_unavailable';
|
|
328
|
+
String get recommendedAction => 'obtainScopeKey';
|
|
329
|
+
EncryptionKeyUnavailable(this.scope, this.field);
|
|
330
|
+
@override
|
|
331
|
+
String toString() =>
|
|
332
|
+
'EncryptionKeyUnavailable: cannot seal "$field": no scope key held for "$scope" (obtain the cap + wrap the scope epoch first)';
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/// THE DEFAULT KeyStore — unwraps this principal's on-chain WrappedKeys with its device secret, caching per
|
|
336
|
+
/// scope. Mirror of the documented reader handshake (e2e_encryption.md): the secret stays client-side, the
|
|
337
|
+
/// operator stays blind. Key custody (where `deviceSecret` lives — enclave, password-derived, …) is the app's.
|
|
338
|
+
class WrappedKeyStore implements NomosKeyStore {
|
|
339
|
+
final NomosBridge bridge;
|
|
340
|
+
final String principal;
|
|
341
|
+
final String deviceSecret;
|
|
342
|
+
final Map<String, Future<List<NomosScopeKey>>> _cache = {};
|
|
343
|
+
WrappedKeyStore(
|
|
344
|
+
{required this.bridge,
|
|
345
|
+
required this.principal,
|
|
346
|
+
required this.deviceSecret});
|
|
347
|
+
|
|
348
|
+
Future<List<NomosScopeKey>> _loadScope(String scope) {
|
|
349
|
+
return _cache.putIfAbsent(scope, () async {
|
|
350
|
+
final rows = await bridge
|
|
351
|
+
.query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
|
|
352
|
+
final held = <NomosScopeKey>[];
|
|
353
|
+
for (final row in rows) {
|
|
354
|
+
final d = ((row['data'] ?? row) as Map);
|
|
355
|
+
if (d['scope']?.toString() != scope) continue;
|
|
356
|
+
final key = await bridge.cryptoUnwrapKey(
|
|
357
|
+
secret: deviceSecret,
|
|
358
|
+
hpkeEpk: d['hpkeEpk'].toString(),
|
|
359
|
+
ct: d['ct'].toString());
|
|
360
|
+
held.add(NomosScopeKey(
|
|
361
|
+
scope: scope,
|
|
362
|
+
epoch: (d['epoch'] as num?)?.toInt() ?? 0,
|
|
363
|
+
key: key));
|
|
364
|
+
}
|
|
365
|
+
held.sort((a, b) => b.epoch - a.epoch);
|
|
366
|
+
return held;
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
@override
|
|
371
|
+
Future<NomosScopeKey?> scopeKey(String scope) async {
|
|
372
|
+
final ks = await _loadScope(scope);
|
|
373
|
+
return ks.isEmpty ? null : ks.first;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
@override
|
|
377
|
+
Future<List<NomosScopeKey>> readKeys(List<String> scopes) async {
|
|
378
|
+
final all = <NomosScopeKey>[];
|
|
379
|
+
for (final s in scopes) {
|
|
380
|
+
all.addAll(await _loadScope(s));
|
|
381
|
+
}
|
|
382
|
+
return all;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// ── E2E OWNER CEREMONY (the sealing client, slice 2) — the owner-side twin of [WrappedKeyStore]. Domain-
|
|
387
|
+
// agnostic, so it lives here (re-exported per client) exactly like the KeyStore. It composes the framework
|
|
388
|
+
// key-epoch fan-out (`advanceKeyEpoch`, dispatched by its REAL id) with the domain's own grant/revoke
|
|
389
|
+
// directive, resolving the roster + each member's wrap-target pubkey FROM THE CHAIN — so the operator-blind
|
|
390
|
+
// handshake (grant → advance epoch → wrap to every member) is ONE call. Mirror of the TS `_ownerEncryption`. ──
|
|
391
|
+
|
|
392
|
+
/// Thrown by the owner ceremony when a roster member has NO wrap-target key (no publishEncKey on chain, no
|
|
393
|
+
/// prior wrap, no supplied override) — the ceremony NEVER wraps to a default (fail-closed; a keyless member
|
|
394
|
+
/// is "not yet a member", per e2e_encryption.md's FOOTGUNS).
|
|
395
|
+
class EncryptionMemberKeyMissing implements Exception {
|
|
396
|
+
final String principal;
|
|
397
|
+
final String scope;
|
|
398
|
+
String get code => 'nomos.crypto.member_key_missing';
|
|
399
|
+
String get recommendedAction => 'publishEncKey';
|
|
400
|
+
EncryptionMemberKeyMissing(this.principal, this.scope);
|
|
401
|
+
@override
|
|
402
|
+
String toString() =>
|
|
403
|
+
'EncryptionMemberKeyMissing: cannot wrap the "$scope" scope key to member "$principal" — no encryption public key on chain (publishEncKey) and none supplied (fail-closed)';
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/// Thrown when the resolved roster != the caller's declared `expectRoster` BEFORE dispatch — pre-firing the
|
|
407
|
+
/// kernel fan-out gate (wrap set == roster) so a rotation against a stale roster view never spends an epoch.
|
|
408
|
+
class EncryptionRosterMismatch implements Exception {
|
|
409
|
+
final String scope;
|
|
410
|
+
final List<String> missing;
|
|
411
|
+
final List<String> extra;
|
|
412
|
+
String get code => 'nomos.crypto.roster_mismatch';
|
|
413
|
+
String get recommendedAction => 'reconcileRoster';
|
|
414
|
+
EncryptionRosterMismatch(this.scope, this.missing, this.extra);
|
|
415
|
+
@override
|
|
416
|
+
String toString() =>
|
|
417
|
+
'EncryptionRosterMismatch: roster for "$scope" != expected (the fan-out gate would refuse): missing $missing, unexpected $extra';
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/// The outcome of an epoch-advancing ceremony verb: the new local [head], the [epoch] opened, and the
|
|
421
|
+
/// [roster] the key was wrapped to.
|
|
422
|
+
class NomosEpochResult {
|
|
423
|
+
final String head;
|
|
424
|
+
final int epoch;
|
|
425
|
+
final List<String> roster;
|
|
426
|
+
const NomosEpochResult(
|
|
427
|
+
{required this.head, required this.epoch, required this.roster});
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/// THE OWNER CEREMONY surface — `<Domain>Client(bridge, actor: owner).encryption`. All three verbs open a
|
|
431
|
+
/// FRESH epoch (a fresh 32-byte secret) and fan the wraps to the resolved roster; the membership change (if
|
|
432
|
+
/// any) is the domain grant/revoke dispatched first, in the same call.
|
|
433
|
+
class NomosEncryption {
|
|
434
|
+
final NomosBridge bridge;
|
|
435
|
+
final String domain;
|
|
436
|
+
final String? domainHash;
|
|
437
|
+
final String? actor;
|
|
438
|
+
|
|
439
|
+
/// The encryption scopes this domain defines (each a `requires(cap)`).
|
|
440
|
+
final List<String> scopes;
|
|
441
|
+
|
|
442
|
+
NomosEncryption(
|
|
443
|
+
{required this.bridge,
|
|
444
|
+
required this.domain,
|
|
445
|
+
this.domainHash,
|
|
446
|
+
this.actor,
|
|
447
|
+
required this.scopes});
|
|
448
|
+
|
|
449
|
+
String _defScope([String? scope]) {
|
|
450
|
+
if (scope != null && scope.isNotEmpty) return scope;
|
|
451
|
+
if (scopes.length == 1) return scopes.first;
|
|
452
|
+
throw StateError(
|
|
453
|
+
'this domain defines ${scopes.length} encryption scopes $scopes — pass a scope to pick one');
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// A BARE scope IS `workspace:self#<relation>` (the era rule); a per-record scope is `<type>:<id>#<relation>`.
|
|
457
|
+
({String object, String relation}) _parseScope(String scope) {
|
|
458
|
+
final h = scope.indexOf('#');
|
|
459
|
+
return h < 0
|
|
460
|
+
? (object: 'workspace:self', relation: scope)
|
|
461
|
+
: (object: scope.substring(0, h), relation: scope.substring(h + 1));
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/// The CURRENT roster of a scope (active direct-grant members, principal-form). Defaults to the sole scope.
|
|
465
|
+
Future<List<String>> roster([String? scope]) => _readRoster(_defScope(scope));
|
|
466
|
+
|
|
467
|
+
Future<List<String>> _readRoster(String scope) async {
|
|
468
|
+
final s = _parseScope(scope);
|
|
469
|
+
final rows =
|
|
470
|
+
await bridge.queryById('reltuple-${s.object}-${s.relation}');
|
|
471
|
+
final subjects = rows.isEmpty
|
|
472
|
+
? const <String, dynamic>{}
|
|
473
|
+
: ((rows.first['data'] as Map?)?['subjects'] as Map?) ??
|
|
474
|
+
const <String, dynamic>{};
|
|
475
|
+
final out = <String>[];
|
|
476
|
+
subjects.forEach((subj, leafRaw) {
|
|
477
|
+
Map? leaf;
|
|
478
|
+
try {
|
|
479
|
+
leaf = leafRaw is String
|
|
480
|
+
? jsonDecode(leafRaw) as Map
|
|
481
|
+
: (leafRaw as Map);
|
|
482
|
+
} catch (_) {
|
|
483
|
+
leaf = null;
|
|
484
|
+
}
|
|
485
|
+
final status = leaf?['status'];
|
|
486
|
+
if (status != null && status != 'active') return;
|
|
487
|
+
final p = subj.toString();
|
|
488
|
+
out.add(p.startsWith('user:') ? p.substring(5) : p);
|
|
489
|
+
});
|
|
490
|
+
out.sort();
|
|
491
|
+
return out;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
Future<int> _currentEpoch(String scope) async {
|
|
495
|
+
final rows = await bridge.queryById('keyscope-$scope');
|
|
496
|
+
if (rows.isEmpty) return -1;
|
|
497
|
+
final e = (rows.first['data'] as Map?)?['currentEpoch'];
|
|
498
|
+
return e is num ? e.toInt() : -1;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
Future<String> _freshScopeKey() async {
|
|
502
|
+
final kp = await bridge.cryptoKeygen();
|
|
503
|
+
final secret = base64Decode((kp['secret'] ?? '').toString());
|
|
504
|
+
return base64Encode(secret.sublist(0, 32)); // the 32-byte epoch secret; NEVER lands on chain
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
Future<String?> _memberPubKey(String principal,
|
|
508
|
+
{String? newcomer, String? newcomerPub, Map<String, String>? memberKeys}) async {
|
|
509
|
+
if (memberKeys != null && memberKeys[principal] != null) {
|
|
510
|
+
return memberKeys[principal];
|
|
511
|
+
}
|
|
512
|
+
if (newcomer == principal && newcomerPub != null) return newcomerPub;
|
|
513
|
+
// Published enc key (encKeyByPrincipal) — present only when the domain composed the registry; tolerate absence.
|
|
514
|
+
try {
|
|
515
|
+
final reg =
|
|
516
|
+
await bridge.query('encKeyByPrincipal', {'principal': principal});
|
|
517
|
+
if (reg.isNotEmpty) {
|
|
518
|
+
final pub = (reg.first['data'] as Map?)?['encPublicKey'];
|
|
519
|
+
if (pub != null && pub.toString().isNotEmpty) return pub.toString();
|
|
520
|
+
}
|
|
521
|
+
} catch (_) {/* not routed — fall through to the prior-wrap lane */}
|
|
522
|
+
// The member's most-recent WrappedKey pins the pubkey it was last sealed to (rotation needs no re-publish).
|
|
523
|
+
try {
|
|
524
|
+
final wraps = await bridge
|
|
525
|
+
.query('wrappedKeysByRecipient', {'recipientPrincipal': principal});
|
|
526
|
+
int bestEpoch = -1;
|
|
527
|
+
String? bestPub;
|
|
528
|
+
for (final w in wraps) {
|
|
529
|
+
final d = (w['data'] as Map?) ?? const {};
|
|
530
|
+
final pub = d['recipientPubKey'];
|
|
531
|
+
final ep = (d['epoch'] as num?)?.toInt() ?? 0;
|
|
532
|
+
if (pub != null && ep >= bestEpoch) {
|
|
533
|
+
bestEpoch = ep;
|
|
534
|
+
bestPub = pub.toString();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (bestPub != null) return bestPub;
|
|
538
|
+
} catch (_) {/* not routed */}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
Future<NomosEpochResult> _advance(
|
|
543
|
+
String scope, {
|
|
544
|
+
String? addPrincipal,
|
|
545
|
+
String? addPublicKey,
|
|
546
|
+
List<String> remove = const [],
|
|
547
|
+
String? scopeKey,
|
|
548
|
+
Map<String, String>? memberKeys,
|
|
549
|
+
List<String>? expectRoster,
|
|
550
|
+
String? openedAt,
|
|
551
|
+
}) async {
|
|
552
|
+
final roster = (await _readRoster(scope)).toSet();
|
|
553
|
+
if (addPrincipal != null) roster.add(addPrincipal);
|
|
554
|
+
roster.removeAll(remove);
|
|
555
|
+
final members = roster.toList()..sort();
|
|
556
|
+
// PRE-FIRE the fan-out gate: if the caller declared the roster it means to wrap, refuse on drift.
|
|
557
|
+
if (expectRoster != null) {
|
|
558
|
+
final want = expectRoster.toSet();
|
|
559
|
+
final missing = (want.difference(roster).toList())..sort();
|
|
560
|
+
final extra = members.where((m) => !want.contains(m)).toList();
|
|
561
|
+
if (missing.isNotEmpty || extra.isNotEmpty) {
|
|
562
|
+
throw EncryptionRosterMismatch(scope, missing, extra);
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
final key = scopeKey ?? await _freshScopeKey();
|
|
566
|
+
final wraps = <Map<String, Object?>>[];
|
|
567
|
+
for (final principal in members) {
|
|
568
|
+
final pub = await _memberPubKey(principal,
|
|
569
|
+
newcomer: addPrincipal, newcomerPub: addPublicKey, memberKeys: memberKeys);
|
|
570
|
+
if (pub == null) throw EncryptionMemberKeyMissing(principal, scope);
|
|
571
|
+
final w = await bridge.cryptoWrapKey(recipientPub: pub, scopeKey: key);
|
|
572
|
+
wraps.add({
|
|
573
|
+
'recipientPrincipal': principal,
|
|
574
|
+
'recipientPubKey': pub,
|
|
575
|
+
'hpkeEpk': (w['hpkeEpk'] ?? '').toString(),
|
|
576
|
+
'ct': (w['ct'] ?? '').toString(),
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
final epoch = (await _currentEpoch(scope)) + 1; // forward-only: strictly increments
|
|
580
|
+
final head = await bridge.offerDirective(
|
|
581
|
+
domain: domain,
|
|
582
|
+
directiveId: 'advanceKeyEpoch',
|
|
583
|
+
payload: {
|
|
584
|
+
'scope': scope,
|
|
585
|
+
'epoch': epoch,
|
|
586
|
+
'openedAt': openedAt ?? DateTime.now().toUtc().toIso8601String(),
|
|
587
|
+
'wraps': wraps,
|
|
588
|
+
},
|
|
589
|
+
actor: actor,
|
|
590
|
+
domainHash: domainHash,
|
|
591
|
+
);
|
|
592
|
+
return NomosEpochResult(head: head, epoch: epoch, roster: members);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/// Grant a newcomer + open a fresh epoch wrapping the key to EVERY member (incl. the newcomer). Dispatches
|
|
596
|
+
/// `grantDirective(grantPayload)` first, then `advanceKeyEpoch`. `publicKey` is the newcomer's X25519 key
|
|
597
|
+
/// (else resolved from an on-chain publishEncKey). Existing members' keys come from their prior wraps.
|
|
598
|
+
Future<NomosEpochResult> grantAndWrap({
|
|
599
|
+
required String principal,
|
|
600
|
+
String? publicKey,
|
|
601
|
+
String? scope,
|
|
602
|
+
required String grantDirective,
|
|
603
|
+
required Map<String, Object?> grantPayload,
|
|
604
|
+
String? scopeKey,
|
|
605
|
+
Map<String, String>? memberKeys,
|
|
606
|
+
List<String>? expectRoster,
|
|
607
|
+
String? openedAt,
|
|
608
|
+
}) async {
|
|
609
|
+
final s = _defScope(scope);
|
|
610
|
+
await bridge.offerDirective(
|
|
611
|
+
domain: domain,
|
|
612
|
+
directiveId: grantDirective,
|
|
613
|
+
payload: grantPayload,
|
|
614
|
+
actor: actor,
|
|
615
|
+
domainHash: domainHash);
|
|
616
|
+
return _advance(s,
|
|
617
|
+
addPrincipal: principal,
|
|
618
|
+
addPublicKey: publicKey,
|
|
619
|
+
scopeKey: scopeKey,
|
|
620
|
+
memberKeys: memberKeys,
|
|
621
|
+
expectRoster: expectRoster,
|
|
622
|
+
openedAt: openedAt);
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/// Revoke a member + open a fresh epoch re-wrapping the key to the SURVIVORS only. FORWARD-ONLY: the removed
|
|
626
|
+
/// member gets no NEW reads, but old ciphertext it already holds a key for stays readable (not re-encrypted).
|
|
627
|
+
Future<NomosEpochResult> revokeAndRotate({
|
|
628
|
+
required String principal,
|
|
629
|
+
String? scope,
|
|
630
|
+
required String revokeDirective,
|
|
631
|
+
required Map<String, Object?> revokePayload,
|
|
632
|
+
String? scopeKey,
|
|
633
|
+
Map<String, String>? memberKeys,
|
|
634
|
+
List<String>? expectRoster,
|
|
635
|
+
String? openedAt,
|
|
636
|
+
}) async {
|
|
637
|
+
final s = _defScope(scope);
|
|
638
|
+
await bridge.offerDirective(
|
|
639
|
+
domain: domain,
|
|
640
|
+
directiveId: revokeDirective,
|
|
641
|
+
payload: revokePayload,
|
|
642
|
+
actor: actor,
|
|
643
|
+
domainHash: domainHash);
|
|
644
|
+
return _advance(s,
|
|
645
|
+
remove: [principal],
|
|
646
|
+
scopeKey: scopeKey,
|
|
647
|
+
memberKeys: memberKeys,
|
|
648
|
+
expectRoster: expectRoster,
|
|
649
|
+
openedAt: openedAt);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
/// Routine rotation — open a fresh epoch re-wrapping the key to the CURRENT roster (no membership change).
|
|
653
|
+
Future<NomosEpochResult> rotate({
|
|
654
|
+
String? scope,
|
|
655
|
+
String? scopeKey,
|
|
656
|
+
Map<String, String>? memberKeys,
|
|
657
|
+
List<String>? expectRoster,
|
|
658
|
+
String? openedAt,
|
|
659
|
+
}) =>
|
|
660
|
+
_advance(_defScope(scope),
|
|
661
|
+
scopeKey: scopeKey,
|
|
662
|
+
memberKeys: memberKeys,
|
|
663
|
+
expectRoster: expectRoster,
|
|
664
|
+
openedAt: openedAt);
|
|
665
|
+
}
|
|
666
|
+
|
|
295
667
|
/// The result of [NomosBridge.offerCreates]: the new local [head] plus every aggregate the plan minted.
|
|
296
668
|
class NomosOfferOutcome {
|
|
297
669
|
final String head;
|
|
@@ -771,14 +1143,78 @@ class NomosBridge {
|
|
|
771
1143
|
Map<String, Object?> args) async =>
|
|
772
1144
|
_asMap(await _call('attestationSign', {'args': args}));
|
|
773
1145
|
|
|
774
|
-
/// A declared query's rows from the local projection.
|
|
1146
|
+
/// A declared query's rows from the local projection. `keys` (E2E sealing) injects the reader's held
|
|
1147
|
+
/// scope keys so encrypted fields AEAD-open in-wasm; absent ⇒ they stay opaque envelopes (today's
|
|
1148
|
+
/// behavior). `principal` stamps the private-read gate (defaults to the connection's bound principal).
|
|
1149
|
+
/// Both are OMITTED from the wire when absent, so a public un-keyed read is byte-identical to before.
|
|
775
1150
|
Future<List<Map<String, dynamic>>> query(String queryId,
|
|
776
|
-
[Map<String, Object?> params = const {}
|
|
777
|
-
|
|
1151
|
+
[Map<String, Object?> params = const {},
|
|
1152
|
+
List<NomosScopeKey>? keys,
|
|
1153
|
+
String? principal]) async =>
|
|
1154
|
+
_asRows(await _call('query', {
|
|
1155
|
+
'queryId': queryId,
|
|
1156
|
+
'params': params,
|
|
1157
|
+
...?_readAuthArgs(keys, principal),
|
|
1158
|
+
}));
|
|
1159
|
+
|
|
1160
|
+
/// A single aggregate by id (the by-id read). `keys`/`principal`: see [query].
|
|
1161
|
+
Future<List<Map<String, dynamic>>> queryById(String id,
|
|
1162
|
+
[List<NomosScopeKey>? keys, String? principal]) async =>
|
|
1163
|
+
_asRows(await _call('queryById', {
|
|
1164
|
+
'id': id,
|
|
1165
|
+
...?_readAuthArgs(keys, principal),
|
|
1166
|
+
}));
|
|
1167
|
+
|
|
1168
|
+
/// The read auth sidecar (private reads + E2E): `keys`/`principal` as top-level call args, or null when
|
|
1169
|
+
/// neither is present (so the wire is byte-identical to before this existed).
|
|
1170
|
+
Map<String, Object?>? _readAuthArgs(List<NomosScopeKey>? keys, String? principal) {
|
|
1171
|
+
final out = <String, Object?>{};
|
|
1172
|
+
if (principal != null && principal.isNotEmpty) out['principal'] = principal;
|
|
1173
|
+
if (keys != null && keys.isNotEmpty) out['keys'] = keys.map((k) => k.toJson()).toList();
|
|
1174
|
+
return out.isEmpty ? null : out;
|
|
1175
|
+
}
|
|
778
1176
|
|
|
779
|
-
///
|
|
780
|
-
|
|
781
|
-
|
|
1177
|
+
/// E2E CRYPTO OPS (operator-blind sealing) — routed to the kernel via the WebView runner. XChaCha isn't in
|
|
1178
|
+
/// WebCrypto/Dart, so these drive the wasm; the scope key + device secret are INJECTED per call and never
|
|
1179
|
+
/// touch the chain. Mirror of the TS holon's crypto seam (@githolon/client).
|
|
1180
|
+
/// Mint an X25519 device keypair for the handshake: `{secret, public}` (base64). Secret stays on device.
|
|
1181
|
+
Future<Map<String, Object?>> cryptoKeygen() async =>
|
|
1182
|
+
_asMap(await _call('cryptoKeygen'));
|
|
1183
|
+
|
|
1184
|
+
/// AUTHOR-SIDE SEAL: encrypt `plaintext` into its on-chain ciphertext envelope, bound to the scope + #58
|
|
1185
|
+
/// stable sids (rename-safe). Returns the envelope string the encrypted field value is written as.
|
|
1186
|
+
Future<String> cryptoSealField(
|
|
1187
|
+
{required String scopeKey,
|
|
1188
|
+
required String scope,
|
|
1189
|
+
required int epoch,
|
|
1190
|
+
required String aggSid,
|
|
1191
|
+
required String fieldSid,
|
|
1192
|
+
required String plaintext}) async =>
|
|
1193
|
+
(_asMap(await _call('cryptoSealField', {
|
|
1194
|
+
'scopeKey': scopeKey,
|
|
1195
|
+
'scope': scope,
|
|
1196
|
+
'epoch': epoch,
|
|
1197
|
+
'aggSid': aggSid,
|
|
1198
|
+
'fieldSid': fieldSid,
|
|
1199
|
+
'plaintext': plaintext,
|
|
1200
|
+
}))['envelope'] ?? '')
|
|
1201
|
+
.toString();
|
|
1202
|
+
|
|
1203
|
+
/// Wrap a scope key TO a recipient's public key (committer side): `{hpkeEpk, ct}` base64 ciphertext.
|
|
1204
|
+
Future<Map<String, Object?>> cryptoWrapKey(
|
|
1205
|
+
{required String recipientPub, required String scopeKey}) async =>
|
|
1206
|
+
_asMap(await _call('cryptoWrapKey',
|
|
1207
|
+
{'recipientPub': recipientPub, 'scopeKey': scopeKey}));
|
|
1208
|
+
|
|
1209
|
+
/// Unwrap an on-chain WrappedKey with this device's secret → the base64 scope key (reader side).
|
|
1210
|
+
Future<String> cryptoUnwrapKey(
|
|
1211
|
+
{required String secret,
|
|
1212
|
+
required String hpkeEpk,
|
|
1213
|
+
required String ct}) async =>
|
|
1214
|
+
(_asMap(await _call('cryptoUnwrapKey',
|
|
1215
|
+
{'secret': secret, 'hpkeEpk': hpkeEpk, 'ct': ct}))['scopeKey'] ??
|
|
1216
|
+
'')
|
|
1217
|
+
.toString();
|
|
782
1218
|
|
|
783
1219
|
/// THE SPATIAL READ: rows of the declared spatial index's of-type whose geometry
|
|
784
1220
|
/// intersects [bbox] — an R*Tree probe on the local projection, same row shape as [query].
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@githolon/dsl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.51.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Nomos 2 domain-authoring DSL: aggregates + directives in TS, executed and encoded to the Rust kernel's wire shapes.",
|
|
6
6
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
@@ -46,7 +46,8 @@
|
|
|
46
46
|
"dart",
|
|
47
47
|
"!dart/.dart_tool",
|
|
48
48
|
"!dart/build",
|
|
49
|
-
"compile_package.mjs"
|
|
49
|
+
"compile_package.mjs",
|
|
50
|
+
"check_package.mjs"
|
|
50
51
|
],
|
|
51
52
|
"publishConfig": {
|
|
52
53
|
"access": "public"
|