ftown 0.20.8 → 0.21.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/dist/centrifugo-client.d.ts +15 -11
- package/dist/centrifugo-client.js +63 -70
- package/dist/centrifugo-client.js.map +1 -1
- package/dist/cli-client.d.ts +11 -1
- package/dist/cli-client.js +23 -3
- package/dist/cli-client.js.map +1 -1
- package/dist/command-rpc.js +4 -8
- package/dist/command-rpc.js.map +1 -1
- package/dist/identity-reset.d.ts +11 -0
- package/dist/identity-reset.js +192 -0
- package/dist/identity-reset.js.map +1 -0
- package/dist/index.js +276 -279
- package/dist/index.js.map +1 -1
- package/dist/ledger-pairing-client.d.ts +28 -0
- package/dist/ledger-pairing-client.js +152 -0
- package/dist/ledger-pairing-client.js.map +1 -0
- package/dist/ledger-relay.d.ts +124 -0
- package/dist/ledger-relay.js +453 -0
- package/dist/ledger-relay.js.map +1 -0
- package/dist/ledger-startup.d.ts +151 -0
- package/dist/ledger-startup.js +203 -0
- package/dist/ledger-startup.js.map +1 -0
- package/dist/relay-token.d.ts +39 -0
- package/dist/relay-token.js +130 -0
- package/dist/relay-token.js.map +1 -0
- package/dist/revocation-gating.d.ts +15 -0
- package/dist/revocation-gating.js +16 -0
- package/dist/revocation-gating.js.map +1 -0
- package/dist/revoked-restart.d.ts +21 -0
- package/dist/revoked-restart.js +55 -0
- package/dist/revoked-restart.js.map +1 -0
- package/dist/security/bridge/identity.d.ts +2 -47
- package/dist/security/bridge/identity.js +3 -3
- package/dist/security/bridge/identity.js.map +1 -1
- package/dist/security/bridge/ledger-identity.d.ts +48 -0
- package/dist/security/bridge/ledger-identity.js +342 -0
- package/dist/security/bridge/ledger-identity.js.map +1 -0
- package/dist/security/bridge/ledger-store.d.ts +84 -0
- package/dist/security/bridge/ledger-store.js +485 -0
- package/dist/security/bridge/ledger-store.js.map +1 -0
- package/dist/security/bridge/metadata.d.ts +2 -2
- package/dist/security/bridge/permissions.js +2 -2
- package/dist/security/bridge/permissions.js.map +1 -1
- package/dist/security/bridge/policy.d.ts +42 -21
- package/dist/security/bridge/policy.js +217 -135
- package/dist/security/bridge/policy.js.map +1 -1
- package/dist/security/bridge/security.d.ts +91 -28
- package/dist/security/bridge/security.js +274 -117
- package/dist/security/bridge/security.js.map +1 -1
- package/dist/security/crypto.js +1 -1
- package/dist/security/crypto.js.map +1 -1
- package/dist/security/ledger/channels.d.ts +5 -0
- package/dist/security/ledger/channels.js +21 -0
- package/dist/security/ledger/channels.js.map +1 -0
- package/dist/security/ledger/ledger.d.ts +61 -0
- package/dist/security/ledger/ledger.js +597 -0
- package/dist/security/ledger/ledger.js.map +1 -0
- package/dist/security/ledger/pairing.d.ts +81 -0
- package/dist/security/ledger/pairing.js +414 -0
- package/dist/security/ledger/pairing.js.map +1 -0
- package/dist/security/ledger/proof.d.ts +24 -0
- package/dist/security/ledger/proof.js +161 -0
- package/dist/security/ledger/proof.js.map +1 -0
- package/dist/security/ledger/protocol.d.ts +100 -0
- package/dist/security/ledger/protocol.js +16 -0
- package/dist/security/ledger/protocol.js.map +1 -0
- package/dist/security/ledger/testing.d.ts +27 -0
- package/dist/security/ledger/testing.js +70 -0
- package/dist/security/ledger/testing.js.map +1 -0
- package/dist/security/ledger/webauthn.d.ts +12 -0
- package/dist/security/ledger/webauthn.js +147 -0
- package/dist/security/ledger/webauthn.js.map +1 -0
- package/dist/security/protocol.d.ts +5 -10
- package/dist/security-cli.d.ts +5 -0
- package/dist/security-cli.js +28 -16
- package/dist/security-cli.js.map +1 -1
- package/dist/token-provider.d.ts +39 -0
- package/dist/token-provider.js +101 -0
- package/dist/token-provider.js.map +1 -0
- package/dist/types.d.ts +1 -1
- package/package.json +2 -2
- package/skills/ftown/references/sessions.md +4 -0
- package/dist/bridge-auth.d.ts +0 -25
- package/dist/bridge-auth.js +0 -56
- package/dist/bridge-auth.js.map +0 -1
- package/dist/pairing-client.d.ts +0 -60
- package/dist/pairing-client.js +0 -460
- package/dist/pairing-client.js.map +0 -1
- package/dist/rotating-token-refresher.d.ts +0 -42
- package/dist/rotating-token-refresher.js +0 -91
- package/dist/rotating-token-refresher.js.map +0 -1
- package/dist/security/account-crypto.d.ts +0 -33
- package/dist/security/account-crypto.js +0 -649
- package/dist/security/account-crypto.js.map +0 -1
- package/dist/security/account-protocol.d.ts +0 -228
- package/dist/security/account-protocol.js +0 -2
- package/dist/security/account-protocol.js.map +0 -1
- package/dist/security/bridge/account-client.d.ts +0 -7
- package/dist/security/bridge/account-client.js +0 -87
- package/dist/security/bridge/account-client.js.map +0 -1
- package/dist/security/bridge/account-state.d.ts +0 -105
- package/dist/security/bridge/account-state.js +0 -79
- package/dist/security/bridge/account-state.js.map +0 -1
- package/dist/security/bridge/pending-approval.d.ts +0 -21
- package/dist/security/bridge/pending-approval.js +0 -80
- package/dist/security/bridge/pending-approval.js.map +0 -1
- package/dist/security/bridge/pending-enrollment.d.ts +0 -33
- package/dist/security/bridge/pending-enrollment.js +0 -177
- package/dist/security/bridge/pending-enrollment.js.map +0 -1
- package/dist/security/pending-approval-crypto.d.ts +0 -29
- package/dist/security/pending-approval-crypto.js +0 -716
- package/dist/security/pending-approval-crypto.js.map +0 -1
- package/dist/security/pending-approval-protocol.d.ts +0 -329
- package/dist/security/pending-approval-protocol.js +0 -2
- package/dist/security/pending-approval-protocol.js.map +0 -1
- package/dist/security/security-nudge.d.ts +0 -31
- package/dist/security/security-nudge.js +0 -98
- package/dist/security/security-nudge.js.map +0 -1
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import type { ApprovedEndpoint, BridgeInvitation, EndpointPermission, EndpointPublic, EnrollmentMessage, InvitationOptions, LocalRecoveryAuthority, VerifiedPrincipal } from '../protocol.js';
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
import { type LedgerCheckpoint } from '../ledger/protocol.js';
|
|
3
|
+
/** Pre-removal policy files carry `mcp: []`, mcp devices and mcp provenance; the strict schema would reject them and brick the bridge.
|
|
4
|
+
* Files written before the ledger carry `account: null` (Solo/local always did): dropped. A non-null v1 account section is kept, so
|
|
5
|
+
* the strict schema refuses it: v1 account state is never reinterpreted (startup resets such a bridge's identity instead). */
|
|
5
6
|
export declare function migrateLegacyPolicy(raw: unknown): unknown;
|
|
6
7
|
/** Identity comparison matters: request JSON cannot manufacture this recovery capability. */
|
|
7
8
|
export declare const LOCAL_RECOVERY_AUTHORITY: LocalRecoveryAuthority;
|
|
8
9
|
export interface PolicyOptions {
|
|
9
10
|
now?: () => number;
|
|
10
|
-
monotonicNow?: () => number;
|
|
11
11
|
relayUrl?: string;
|
|
12
12
|
localOrigin?: string;
|
|
13
13
|
}
|
|
14
|
+
/** This bridge in its trusted ledger: 'unpaired' = no ledger stored, 'absent' = not listed with exactly its keys (or another
|
|
15
|
+
* account's ledger), 'revoked' = its bridgeId is revoked (signed, or sticky from any chain the replica kept or adopted). */
|
|
16
|
+
export type LedgerOwnStatus = 'active' | 'revoked' | 'absent' | 'unpaired';
|
|
17
|
+
/** The account-mode authority, held in memory and replaced only by BridgeSecurityService. */
|
|
18
|
+
export interface LedgerView {
|
|
19
|
+
/** applyRevocations(checkpoint, sticky) of the stored chain; null while no ledger is stored. Never the raw checkpoint. */
|
|
20
|
+
trusted: LedgerCheckpoint | null;
|
|
21
|
+
own: LedgerOwnStatus;
|
|
22
|
+
/** The ledger store's lastServerReadAt (null = never). Read once, on the first projection; afterwards contacts set it. */
|
|
23
|
+
lastServerReadAt: number | null;
|
|
24
|
+
}
|
|
14
25
|
/** Bridge-owned authorization. Directory entries and caller-claimed permissions never enter this store. */
|
|
15
26
|
export declare class BridgePolicyStore {
|
|
16
27
|
readonly bridgeId: string;
|
|
@@ -18,10 +29,22 @@ export declare class BridgePolicyStore {
|
|
|
18
29
|
private readonly options;
|
|
19
30
|
private readonly snapshot;
|
|
20
31
|
private readonly now;
|
|
21
|
-
private readonly
|
|
32
|
+
private readonly clockFile;
|
|
33
|
+
/** Account mode (enableLedger): null for Solo and --local, whose devices come only from invitations and the local CLI. */
|
|
34
|
+
private ledger;
|
|
22
35
|
constructor(dataDir: string, bridgeId: string, bridge: EndpointPublic, options?: PolicyOptions);
|
|
23
36
|
private check;
|
|
24
37
|
private active;
|
|
38
|
+
/** Account mode. A revoked id is never trusted again. The OS-local CLI stays outside the ledger; every other endpoint must be a
|
|
39
|
+
* projected ledger device that the CURRENT trusted view lists with the same key, while this bridge is active and in offline grace. */
|
|
40
|
+
private ledgerAccess;
|
|
41
|
+
/** Written so that NaN, an unknown contact or an unread clock file fails closed. A passing check raises the high-water. */
|
|
42
|
+
private withinGrace;
|
|
43
|
+
/** Loads the persisted high-water once (absent: none; unreadable: a contact in this process is then required). */
|
|
44
|
+
private loadClock;
|
|
45
|
+
/** Persists the observed high-water (after a contact, and on close). An unreadable file is only replaced after a fresh contact. */
|
|
46
|
+
persistLedgerClock(): Promise<void>;
|
|
47
|
+
private refuseInAccountMode;
|
|
25
48
|
private principalFrom;
|
|
26
49
|
private authorize;
|
|
27
50
|
principal(endpointId: string): Promise<VerifiedPrincipal>;
|
|
@@ -32,21 +55,19 @@ export declare class BridgePolicyStore {
|
|
|
32
55
|
recipients(): Promise<ApprovedEndpoint[]>;
|
|
33
56
|
revoke(actor: VerifiedPrincipal, endpointId: string): Promise<void>;
|
|
34
57
|
provisionLocalEndpoint(endpoint: EndpointPublic): Promise<void>;
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
revoked: string[];
|
|
50
|
-
}>;
|
|
58
|
+
/** Enters account mode for this instance (irreversible): until projectLedger supplies a view, every non-local endpoint fails closed,
|
|
59
|
+
* and invitations are refused. Only BridgeSecurityService calls this, with a ledger configured. */
|
|
60
|
+
enableLedger(now: () => number, monotonic: () => number): void;
|
|
61
|
+
/**
|
|
62
|
+
* Account mode only. Installs `view` synchronously (a revocation never waits for the write below), then makes the provenance-'account'
|
|
63
|
+
* devices exactly `view.trusted.devices` (none when trusted is null or another account's). Devices of any other provenance are never
|
|
64
|
+
* touched, and a ledger device whose id is already held by one is skipped. Returns the endpoint ids that lost account membership.
|
|
65
|
+
* `view.lastServerReadAt` is taken only on the first projection; later contacts come from noteServerRead.
|
|
66
|
+
*/
|
|
67
|
+
projectLedger(view: LedgerView): Promise<string[]>;
|
|
68
|
+
/** Account mode: a successful server contact at wall time `at` re-anchors offline grace (lastServerReadAt = at, even if earlier: a
|
|
69
|
+
* contact stamped during a forward clock jump never outlives its correction), restarts the monotonic bound and raises the high-water.
|
|
70
|
+
* Returns `at`, or null (nothing changed) outside account mode or for an invalid time. */
|
|
71
|
+
noteServerRead(at: number): number | null;
|
|
51
72
|
close(): Promise<void>;
|
|
52
73
|
}
|
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { legacyDateTimeSchema } from '../../zod-compat.js';
|
|
2
2
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import {
|
|
3
|
+
import { mkdir, open, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
4
5
|
import { z } from 'zod';
|
|
5
6
|
import { BridgeSecurityError } from './errors.js';
|
|
6
7
|
import { approvedSchema, displayLabel, endpointSchema, permissionSchema } from './metadata.js';
|
|
7
8
|
import { AtomicSnapshot } from './snapshot.js';
|
|
8
|
-
import {
|
|
9
|
-
import { AccountConnectivity, storedAccountSchema } from './account-state.js';
|
|
9
|
+
import { LEDGER_LIMITS } from '../ledger/protocol.js';
|
|
10
10
|
const FIVE_MINUTES = 300_000;
|
|
11
11
|
const MAX_ENDPOINTS = 1000;
|
|
12
12
|
const digest = (secret) => createHash('sha256').update(secret).digest('hex');
|
|
13
13
|
const invitationSchema = z.object({ id: z.string().guid(), secretHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
14
14
|
label: z.string().max(100), permissions: z.array(permissionSchema).max(3), expiresAt: legacyDateTimeSchema,
|
|
15
|
-
consumedBy: z.string().nullable(), provenance: z.
|
|
16
|
-
/** Pre-removal policy files carry `mcp: []`, mcp devices and mcp provenance; the strict schema would reject them and brick the bridge.
|
|
15
|
+
consumedBy: z.string().nullable(), provenance: z.literal('legacy').default('legacy') }).strict();
|
|
16
|
+
/** Pre-removal policy files carry `mcp: []`, mcp devices and mcp provenance; the strict schema would reject them and brick the bridge.
|
|
17
|
+
* Files written before the ledger carry `account: null` (Solo/local always did): dropped. A non-null v1 account section is kept, so
|
|
18
|
+
* the strict schema refuses it: v1 account state is never reinterpreted (startup resets such a bridge's identity instead). */
|
|
17
19
|
export function migrateLegacyPolicy(raw) {
|
|
18
20
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
19
21
|
return raw;
|
|
20
22
|
const { mcp: _mcp, ...rest } = raw;
|
|
23
|
+
if (rest.account === null)
|
|
24
|
+
delete rest.account;
|
|
21
25
|
const isMcpDevice = (device) => typeof device === 'object' && device !== null
|
|
22
26
|
&& device.endpoint?.kind === 'mcp';
|
|
23
27
|
const dropped = new Set();
|
|
@@ -40,8 +44,8 @@ const policySchema = z.preprocess(migrateLegacyPolicy, z.object({ version: z.lit
|
|
|
40
44
|
invitations: z.array(invitationSchema).max(256),
|
|
41
45
|
// Frozen: earlier releases require this key, so a downgrade must still parse what we write.
|
|
42
46
|
mcp: z.array(z.never()).max(0).default([]),
|
|
43
|
-
account:
|
|
44
|
-
provenance: z.record(z.string(), z.enum(['legacy', '
|
|
47
|
+
// 'account': projected from the ledger replica (account mode only). 'legacy': enrolled by invitation. 'local-cli': OS-local CLI.
|
|
48
|
+
provenance: z.record(z.string(), z.enum(['legacy', 'account', 'local-cli'])).default({}),
|
|
45
49
|
}).strict().superRefine((state, context) => {
|
|
46
50
|
if (new Set(state.devices.map(device => device.endpoint.id)).size !== state.devices.length
|
|
47
51
|
|| state.devices.some(device => device.endpoint.ownerId !== state.ownerId
|
|
@@ -52,6 +56,59 @@ const policySchema = z.preprocess(migrateLegacyPolicy, z.object({ version: z.lit
|
|
|
52
56
|
}));
|
|
53
57
|
/** Identity comparison matters: request JSON cannot manufacture this recovery capability. */
|
|
54
58
|
export const LOCAL_RECOVERY_AUTHORITY = Object.freeze({ source: 'os-local-recovery' });
|
|
59
|
+
const isTime = (value) => value !== null && Number.isSafeInteger(value) && value >= 0;
|
|
60
|
+
const CLOCK_KEYS = ['version', 'observedAt'];
|
|
61
|
+
async function readClock(file) {
|
|
62
|
+
let text;
|
|
63
|
+
try {
|
|
64
|
+
const handle = await open(file, 'r');
|
|
65
|
+
try {
|
|
66
|
+
if ((await handle.stat()).size > 4096)
|
|
67
|
+
return 'corrupt';
|
|
68
|
+
text = await handle.readFile('utf8');
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
await handle.close();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
return error.code === 'ENOENT' ? 'absent' : 'corrupt';
|
|
76
|
+
}
|
|
77
|
+
try {
|
|
78
|
+
const value = JSON.parse(text);
|
|
79
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value))
|
|
80
|
+
return 'corrupt';
|
|
81
|
+
const record = value;
|
|
82
|
+
const keys = Object.keys(record);
|
|
83
|
+
const observedAt = record.observedAt;
|
|
84
|
+
if (keys.length !== CLOCK_KEYS.length || keys.some(key => !CLOCK_KEYS.includes(key)) || record.version !== 1
|
|
85
|
+
|| typeof observedAt !== 'number' || !isTime(observedAt))
|
|
86
|
+
return 'corrupt';
|
|
87
|
+
return observedAt;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return 'corrupt';
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function writeClock(file, observedAt) {
|
|
94
|
+
const temporary = `${file}.${randomUUID()}.tmp`;
|
|
95
|
+
try {
|
|
96
|
+
await mkdir(dirname(file), { recursive: true, mode: 0o700 });
|
|
97
|
+
const handle = await open(temporary, 'wx', 0o600);
|
|
98
|
+
try {
|
|
99
|
+
await handle.writeFile(JSON.stringify({ version: 1, observedAt }));
|
|
100
|
+
await handle.sync();
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
await handle.close();
|
|
104
|
+
}
|
|
105
|
+
await rename(temporary, file);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
await rm(temporary, { force: true }).catch(() => undefined);
|
|
109
|
+
throw new BridgeSecurityError('storage_unavailable');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
55
112
|
/** Bridge-owned authorization. Directory entries and caller-claimed permissions never enter this store. */
|
|
56
113
|
export class BridgePolicyStore {
|
|
57
114
|
bridgeId;
|
|
@@ -59,7 +116,9 @@ export class BridgePolicyStore {
|
|
|
59
116
|
options;
|
|
60
117
|
snapshot;
|
|
61
118
|
now;
|
|
62
|
-
|
|
119
|
+
clockFile;
|
|
120
|
+
/** Account mode (enableLedger): null for Solo and --local, whose devices come only from invitations and the local CLI. */
|
|
121
|
+
ledger = null;
|
|
63
122
|
constructor(dataDir, bridgeId, bridge, options = {}) {
|
|
64
123
|
this.bridgeId = bridgeId;
|
|
65
124
|
this.bridge = bridge;
|
|
@@ -68,10 +127,10 @@ export class BridgePolicyStore {
|
|
|
68
127
|
if (bridge.kind !== 'bridge')
|
|
69
128
|
throw new BridgeSecurityError('wrong_context');
|
|
70
129
|
this.now = options.now ?? Date.now;
|
|
71
|
-
this.
|
|
130
|
+
this.clockFile = join(dataDir, 'security', 'ledger-clock.json');
|
|
72
131
|
this.snapshot = new AtomicSnapshot(join(dataDir, 'security', 'policy.json'), policySchema, () => ({
|
|
73
132
|
version: 1, ownerId: bridge.ownerId, bridgeId, bridgeEndpointId: bridge.id, generation: 0,
|
|
74
|
-
devices: [], invitations: [], mcp: [],
|
|
133
|
+
devices: [], invitations: [], mcp: [], provenance: {},
|
|
75
134
|
}));
|
|
76
135
|
}
|
|
77
136
|
check(state) {
|
|
@@ -81,23 +140,89 @@ export class BridgePolicyStore {
|
|
|
81
140
|
}
|
|
82
141
|
active(state, endpointId) {
|
|
83
142
|
this.check(state);
|
|
84
|
-
const account = state.account;
|
|
85
|
-
const member = account?.checkpoint.devices.find(item => item.endpoint.id === endpointId);
|
|
86
|
-
if (member) {
|
|
87
|
-
this.connectivity.check(account);
|
|
88
|
-
const own = account.checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
|
|
89
|
-
if (!own || own.endpoint.id !== this.bridge.id || own.endpoint.publicKey !== this.bridge.publicKey)
|
|
90
|
-
throw new BridgeSecurityError('account_bridge_revoked');
|
|
91
|
-
}
|
|
92
|
-
else if (account?.checkpoint.revokedDeviceIds.includes(endpointId))
|
|
93
|
-
throw new BridgeSecurityError('account_device_revoked');
|
|
94
143
|
const record = state.devices.find(device => device.endpoint.id === endpointId);
|
|
144
|
+
if (this.ledger)
|
|
145
|
+
this.ledgerAccess(state, endpointId, record);
|
|
146
|
+
// Without a ledger nothing vouches for a projected account device: it never authorizes.
|
|
147
|
+
else if (state.provenance[endpointId] === 'account')
|
|
148
|
+
throw new BridgeSecurityError('approval_required');
|
|
95
149
|
if (!record)
|
|
96
150
|
throw new BridgeSecurityError('approval_required');
|
|
97
151
|
if (record.expiresAt && Date.parse(record.expiresAt) <= this.now())
|
|
98
152
|
throw new BridgeSecurityError('expired');
|
|
99
153
|
return record;
|
|
100
154
|
}
|
|
155
|
+
/** Account mode. A revoked id is never trusted again. The OS-local CLI stays outside the ledger; every other endpoint must be a
|
|
156
|
+
* projected ledger device that the CURRENT trusted view lists with the same key, while this bridge is active and in offline grace. */
|
|
157
|
+
ledgerAccess(state, endpointId, record) {
|
|
158
|
+
const view = this.ledger.view;
|
|
159
|
+
if (view.trusted?.revokedDeviceIds.includes(endpointId))
|
|
160
|
+
throw new BridgeSecurityError('account_device_revoked');
|
|
161
|
+
if (!record || record.endpoint.kind === 'local-cli')
|
|
162
|
+
return;
|
|
163
|
+
if (state.provenance[endpointId] !== 'account')
|
|
164
|
+
throw new BridgeSecurityError('approval_required');
|
|
165
|
+
if (view.trusted === null)
|
|
166
|
+
throw new BridgeSecurityError('account_offline_expired');
|
|
167
|
+
if (view.own !== 'active')
|
|
168
|
+
throw new BridgeSecurityError('account_bridge_revoked');
|
|
169
|
+
if (!this.withinGrace())
|
|
170
|
+
throw new BridgeSecurityError('account_offline_expired');
|
|
171
|
+
const member = view.trusted.devices.find(item => item.endpoint.id === endpointId);
|
|
172
|
+
if (!member || member.endpoint.publicKey !== record.endpoint.publicKey)
|
|
173
|
+
throw new BridgeSecurityError('account_device_revoked');
|
|
174
|
+
}
|
|
175
|
+
/** Written so that NaN, an unknown contact or an unread clock file fails closed. A passing check raises the high-water. */
|
|
176
|
+
withinGrace() {
|
|
177
|
+
const gate = this.ledger;
|
|
178
|
+
const at = gate.now();
|
|
179
|
+
const monotonic = gate.monotonic();
|
|
180
|
+
const last = gate.lastServerReadAt;
|
|
181
|
+
const { offlineGraceMs: grace, maxFutureSkewMs: skew } = LEDGER_LIMITS;
|
|
182
|
+
const within = gate.clock !== 'loading' && (gate.clock === 'trusted' || gate.contactAt !== null)
|
|
183
|
+
&& isTime(last) && Number.isSafeInteger(at)
|
|
184
|
+
// The wall clock went back before the last contact, or before a time this bridge already observed (also across restarts).
|
|
185
|
+
&& at >= last - skew && at >= gate.highWater - skew
|
|
186
|
+
&& at - last <= grace
|
|
187
|
+
&& (gate.contactAt === null || (Number.isFinite(monotonic) && monotonic >= gate.contactAt && monotonic - gate.contactAt <= grace));
|
|
188
|
+
if (within && at > gate.highWater)
|
|
189
|
+
gate.highWater = at;
|
|
190
|
+
return within;
|
|
191
|
+
}
|
|
192
|
+
/** Loads the persisted high-water once (absent: none; unreadable: a contact in this process is then required). */
|
|
193
|
+
loadClock() {
|
|
194
|
+
const gate = this.ledger;
|
|
195
|
+
gate.clockLoad ??= readClock(this.clockFile).then(stored => {
|
|
196
|
+
if (typeof stored === 'number') {
|
|
197
|
+
gate.highWater = Math.max(gate.highWater, stored);
|
|
198
|
+
gate.persisted = stored;
|
|
199
|
+
}
|
|
200
|
+
gate.clock = stored === 'corrupt' ? 'untrusted' : 'trusted';
|
|
201
|
+
});
|
|
202
|
+
return gate.clockLoad;
|
|
203
|
+
}
|
|
204
|
+
/** Persists the observed high-water (after a contact, and on close). An unreadable file is only replaced after a fresh contact. */
|
|
205
|
+
async persistLedgerClock() {
|
|
206
|
+
const gate = this.ledger;
|
|
207
|
+
if (!gate)
|
|
208
|
+
return;
|
|
209
|
+
await this.loadClock();
|
|
210
|
+
if (gate.clock === 'untrusted' && gate.contactAt === null)
|
|
211
|
+
return;
|
|
212
|
+
const write = gate.clockWrite.then(async () => {
|
|
213
|
+
const observedAt = gate.highWater;
|
|
214
|
+
if (observedAt <= gate.persisted)
|
|
215
|
+
return;
|
|
216
|
+
await writeClock(this.clockFile, observedAt);
|
|
217
|
+
gate.persisted = Math.max(gate.persisted, observedAt);
|
|
218
|
+
});
|
|
219
|
+
gate.clockWrite = write.catch(() => undefined);
|
|
220
|
+
await write;
|
|
221
|
+
}
|
|
222
|
+
refuseInAccountMode() {
|
|
223
|
+
if (this.ledger)
|
|
224
|
+
throw new BridgeSecurityError('account_managed');
|
|
225
|
+
}
|
|
101
226
|
principalFrom(state, record) {
|
|
102
227
|
return { endpoint: record.endpoint, bridgeId: this.bridgeId, permissions: record.permissions,
|
|
103
228
|
policyGeneration: state.generation, grantId: record.grantId };
|
|
@@ -135,16 +260,15 @@ export class BridgePolicyStore {
|
|
|
135
260
|
const expiresAt = new Date(this.now() + FIVE_MINUTES).toISOString();
|
|
136
261
|
await this.snapshot.transact(state => {
|
|
137
262
|
this.check(state);
|
|
138
|
-
|
|
263
|
+
// Account mode: browsers join only through the ledger, whoever asks.
|
|
264
|
+
this.refuseInAccountMode();
|
|
265
|
+
if (actor !== LOCAL_RECOVERY_AUTHORITY)
|
|
139
266
|
this.authorize(state, actor, 'admin');
|
|
140
|
-
if (state.account && actor.endpoint.kind !== 'local-cli')
|
|
141
|
-
throw new BridgeSecurityError('account_approval_required');
|
|
142
|
-
}
|
|
143
267
|
state.invitations = state.invitations.filter(invitation => Date.parse(invitation.expiresAt) > this.now());
|
|
144
268
|
if (state.invitations.length >= 256)
|
|
145
269
|
throw new BridgeSecurityError('capacity_exceeded');
|
|
146
270
|
state.invitations.push({ id: invitationId, secretHash: digest(secret), label: displayLabel(options.label),
|
|
147
|
-
permissions: ['read', 'control', 'admin'], expiresAt, consumedBy: null, provenance:
|
|
271
|
+
permissions: ['read', 'control', 'admin'], expiresAt, consumedBy: null, provenance: 'legacy' });
|
|
148
272
|
});
|
|
149
273
|
return { version: 1, invitationId, ownerId: this.bridge.ownerId, bridgeId: this.bridgeId, bridge: this.bridge,
|
|
150
274
|
secret, expiresAt, relayUrl: this.options.relayUrl ?? null, localOrigin: this.options.localOrigin ?? null };
|
|
@@ -162,18 +286,13 @@ export class BridgePolicyStore {
|
|
|
162
286
|
throw new BridgeSecurityError('invitation_invalid');
|
|
163
287
|
return this.snapshot.transact(state => {
|
|
164
288
|
this.check(state);
|
|
289
|
+
this.refuseInAccountMode();
|
|
165
290
|
const invitation = state.invitations.find(item => item.id === message.invitationId);
|
|
166
291
|
if (!invitation || !timingSafeEqual(Buffer.from(invitation.secretHash), Buffer.from(digest(message.invitationSecret)))) {
|
|
167
292
|
throw new BridgeSecurityError('invitation_invalid');
|
|
168
293
|
}
|
|
169
294
|
if (Date.parse(invitation.expiresAt) <= this.now())
|
|
170
295
|
throw new BridgeSecurityError('invitation_expired');
|
|
171
|
-
if (state.account && invitation.provenance !== 'local-recovery')
|
|
172
|
-
throw new BridgeSecurityError('invitation_invalid');
|
|
173
|
-
// Independent recovery must never shadow an account membership or revive its tombstone.
|
|
174
|
-
if (state.account && (state.account.checkpoint.devices.some(item => item.endpoint.id === candidate.id)
|
|
175
|
-
|| state.account.checkpoint.revokedDeviceIds.includes(candidate.id)))
|
|
176
|
-
throw new BridgeSecurityError('account_approval_required');
|
|
177
296
|
if (invitation.consumedBy) {
|
|
178
297
|
if (invitation.consumedBy !== candidate.id)
|
|
179
298
|
throw new BridgeSecurityError('invitation_consumed');
|
|
@@ -200,7 +319,7 @@ export class BridgePolicyStore {
|
|
|
200
319
|
const provenance = state.provenance[device.endpoint.id];
|
|
201
320
|
if (provenance === 'account')
|
|
202
321
|
return { ...device, source: 'account' };
|
|
203
|
-
if (provenance === 'local-cli'
|
|
322
|
+
if (provenance === 'local-cli')
|
|
204
323
|
return { ...device, source: 'local' };
|
|
205
324
|
return { ...device };
|
|
206
325
|
});
|
|
@@ -222,8 +341,10 @@ export class BridgePolicyStore {
|
|
|
222
341
|
async revoke(actor, endpointId) {
|
|
223
342
|
await this.snapshot.transact(state => {
|
|
224
343
|
this.authorize(state, actor, 'admin');
|
|
225
|
-
|
|
226
|
-
|
|
344
|
+
// A ledger device leaves only by a passkey-signed revoke_device; the bridge never drops it on request.
|
|
345
|
+
if (this.ledger && (state.provenance[endpointId] === 'account'
|
|
346
|
+
|| this.ledger.view.trusted?.devices.some(item => item.endpoint.id === endpointId)))
|
|
347
|
+
throw new BridgeSecurityError('account_managed');
|
|
227
348
|
state.devices = state.devices.filter(device => device.endpoint.id !== endpointId);
|
|
228
349
|
delete state.provenance[endpointId];
|
|
229
350
|
state.generation++;
|
|
@@ -245,116 +366,77 @@ export class BridgePolicyStore {
|
|
|
245
366
|
state.provenance[candidate.id] = 'local-cli';
|
|
246
367
|
});
|
|
247
368
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
369
|
+
/** Enters account mode for this instance (irreversible): until projectLedger supplies a view, every non-local endpoint fails closed,
|
|
370
|
+
* and invitations are refused. Only BridgeSecurityService calls this, with a ledger configured. */
|
|
371
|
+
enableLedger(now, monotonic) {
|
|
372
|
+
this.ledger ??= { view: { trusted: null, own: 'unpaired' }, now, monotonic, loaded: false, lastServerReadAt: null, contactAt: null,
|
|
373
|
+
highWater: 0, persisted: 0, clock: 'loading', clockLoad: null, clockWrite: Promise.resolve() };
|
|
253
374
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
375
|
+
/**
|
|
376
|
+
* Account mode only. Installs `view` synchronously (a revocation never waits for the write below), then makes the provenance-'account'
|
|
377
|
+
* devices exactly `view.trusted.devices` (none when trusted is null or another account's). Devices of any other provenance are never
|
|
378
|
+
* touched, and a ledger device whose id is already held by one is skipped. Returns the endpoint ids that lost account membership.
|
|
379
|
+
* `view.lastServerReadAt` is taken only on the first projection; later contacts come from noteServerRead.
|
|
380
|
+
*/
|
|
381
|
+
projectLedger(view) {
|
|
382
|
+
const ledger = this.ledger;
|
|
383
|
+
if (!ledger)
|
|
384
|
+
return Promise.reject(new BridgeSecurityError('wrong_context'));
|
|
385
|
+
const trusted = view.trusted !== null && view.trusted.accountId === this.bridge.ownerId ? view.trusted : null;
|
|
386
|
+
ledger.view = { trusted, own: trusted === null && view.own === 'active' ? 'absent' : view.own };
|
|
387
|
+
if (!ledger.loaded) {
|
|
388
|
+
ledger.loaded = true;
|
|
389
|
+
ledger.lastServerReadAt = isTime(view.lastServerReadAt) ? view.lastServerReadAt : null;
|
|
390
|
+
}
|
|
391
|
+
const clock = this.loadClock();
|
|
392
|
+
const members = trusted === null ? [] : trusted.devices;
|
|
393
|
+
return clock.then(() => this.snapshot.transact(state => {
|
|
257
394
|
this.check(state);
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
395
|
+
const removed = [];
|
|
396
|
+
state.devices = state.devices.filter(device => {
|
|
397
|
+
const id = device.endpoint.id;
|
|
398
|
+
if (state.provenance[id] !== 'account')
|
|
399
|
+
return true;
|
|
400
|
+
const member = members.find(item => item.endpoint.id === id);
|
|
401
|
+
if (member && member.endpoint.publicKey === device.endpoint.publicKey)
|
|
402
|
+
return true;
|
|
403
|
+
removed.push(id);
|
|
404
|
+
delete state.provenance[id];
|
|
265
405
|
return false;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
this.check(state);
|
|
273
|
-
if (state.account) {
|
|
274
|
-
if (state.account.checkpoint.rootId !== trust.checkpoint.rootId)
|
|
275
|
-
throw new BridgeSecurityError('account_trust_rollback');
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
const checkpoint = await verifyAccountHistory(trust.history, trust.checkpoint, this.now());
|
|
279
|
-
this.requireOwnBridge(checkpoint);
|
|
280
|
-
this.projectAccount(state, { ...trust, checkpoint, lastServerReadAt: 0, lastObservedAt: 0 });
|
|
281
|
-
});
|
|
282
|
-
}
|
|
283
|
-
requireOwnBridge(checkpoint) {
|
|
284
|
-
const own = checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
|
|
285
|
-
if (checkpoint.ownerId !== this.bridge.ownerId || !own || own.endpoint.id !== this.bridge.id
|
|
286
|
-
|| own.endpoint.publicKey !== this.bridge.publicKey || own.endpoint.ownerId !== this.bridge.ownerId)
|
|
287
|
-
throw new BridgeSecurityError('wrong_context');
|
|
288
|
-
}
|
|
289
|
-
projectAccount(state, trust) {
|
|
290
|
-
const previous = state.devices.filter(item => item.endpoint.kind === 'browser');
|
|
291
|
-
const first = state.account === null;
|
|
292
|
-
const unchanged = state.account?.checkpoint.head === trust.checkpoint.head;
|
|
293
|
-
state.devices = state.devices.filter(item => item.endpoint.kind !== 'browser'
|
|
294
|
-
|| (!first && state.provenance[item.endpoint.id] === 'local-recovery'
|
|
295
|
-
&& !trust.checkpoint.devices.some(member => member.endpoint.id === item.endpoint.id)
|
|
296
|
-
&& !trust.checkpoint.revokedDeviceIds.includes(item.endpoint.id)));
|
|
297
|
-
if (first)
|
|
298
|
-
state.invitations = [];
|
|
299
|
-
for (const entry of previous)
|
|
300
|
-
if (!state.devices.some(item => item.endpoint.id === entry.endpoint.id))
|
|
301
|
-
delete state.provenance[entry.endpoint.id];
|
|
302
|
-
const own = trust.checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
|
|
303
|
-
if (own?.endpoint.id === this.bridge.id && own.endpoint.publicKey === this.bridge.publicKey) {
|
|
304
|
-
for (const member of trust.checkpoint.devices) {
|
|
305
|
-
if (state.devices.some(item => item.endpoint.id === member.endpoint.id))
|
|
306
|
-
throw new BridgeSecurityError('wrong_context');
|
|
406
|
+
});
|
|
407
|
+
for (const member of members) {
|
|
408
|
+
if (state.devices.some(device => device.endpoint.id === member.endpoint.id))
|
|
409
|
+
continue;
|
|
410
|
+
if (state.devices.length >= MAX_ENDPOINTS)
|
|
411
|
+
throw new BridgeSecurityError('capacity_exceeded');
|
|
307
412
|
state.devices.push({ endpoint: member.endpoint, label: displayLabel(member.label), permissions: ['read', 'control', 'admin'],
|
|
308
|
-
approvedAt: member.approvedAt, grantId: null, expiresAt: null });
|
|
413
|
+
approvedAt: new Date(member.approvedAt).toISOString(), grantId: null, expiresAt: null });
|
|
309
414
|
state.provenance[member.endpoint.id] = 'account';
|
|
310
415
|
}
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
416
|
+
// Never the global generation: a ledger change must not disturb the principals it leaves in place. A removed device is
|
|
417
|
+
// invalidated on its own: its record is gone (and the view refuses it), so its next principal/revalidate fails, and the
|
|
418
|
+
// caller emits onRevoked for it.
|
|
419
|
+
return removed;
|
|
420
|
+
}));
|
|
316
421
|
}
|
|
317
|
-
/**
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
throw new BridgeSecurityError('wrong_context');
|
|
331
|
-
if (adoption) {
|
|
332
|
-
const head = adoption.head;
|
|
333
|
-
if (head.rootId !== checkpoint.rootId || head.revision !== checkpoint.revision || head.head !== checkpoint.head)
|
|
334
|
-
throw new BridgeSecurityError('account_trust_conflict');
|
|
335
|
-
this.requireOwnBridge(checkpoint);
|
|
336
|
-
const member = checkpoint.devices.find(item => item.endpoint.id === adoption.actor.endpoint.id);
|
|
337
|
-
if (!member || member.endpoint.publicKey !== adoption.actor.endpoint.publicKey || member.endpoint.ownerId !== adoption.actor.endpoint.ownerId) {
|
|
338
|
-
throw new BridgeSecurityError('account_device_revoked');
|
|
339
|
-
}
|
|
340
|
-
this.authorize(state, adoption.actor, 'admin');
|
|
341
|
-
}
|
|
342
|
-
const initial = state.account ?? { checkpoint, history, lastServerReadAt: 0, lastObservedAt: 0 };
|
|
343
|
-
const now = this.connectivity.observed(initial);
|
|
344
|
-
const trust = { checkpoint, history, lastServerReadAt: Math.max(initial.lastServerReadAt, now),
|
|
345
|
-
lastObservedAt: Math.max(initial.lastObservedAt, now) };
|
|
346
|
-
const revoked = this.projectAccount(state, trust);
|
|
347
|
-
return { head: { rootId: checkpoint.rootId, revision: checkpoint.revision, head: checkpoint.head }, revoked, trust };
|
|
348
|
-
});
|
|
349
|
-
this.connectivity.renew(result.trust);
|
|
350
|
-
return { head: result.head, revoked: result.revoked };
|
|
422
|
+
/** Account mode: a successful server contact at wall time `at` re-anchors offline grace (lastServerReadAt = at, even if earlier: a
|
|
423
|
+
* contact stamped during a forward clock jump never outlives its correction), restarts the monotonic bound and raises the high-water.
|
|
424
|
+
* Returns `at`, or null (nothing changed) outside account mode or for an invalid time. */
|
|
425
|
+
noteServerRead(at) {
|
|
426
|
+
const ledger = this.ledger;
|
|
427
|
+
if (!ledger || !isTime(at))
|
|
428
|
+
return null;
|
|
429
|
+
ledger.loaded = true;
|
|
430
|
+
ledger.lastServerReadAt = at;
|
|
431
|
+
ledger.contactAt = ledger.monotonic();
|
|
432
|
+
if (at > ledger.highWater)
|
|
433
|
+
ledger.highWater = at;
|
|
434
|
+
return at;
|
|
351
435
|
}
|
|
352
436
|
async close() {
|
|
353
|
-
|
|
354
|
-
this.
|
|
355
|
-
|
|
356
|
-
state.account.lastObservedAt = Math.max(state.account.lastObservedAt, this.connectivity.highwater());
|
|
357
|
-
});
|
|
437
|
+
if (this.ledger) {
|
|
438
|
+
await this.persistLedgerClock().catch((error) => console.error('[Security] Saving the ledger clock failed:', error instanceof Error ? error.message : String(error)));
|
|
439
|
+
}
|
|
358
440
|
await this.snapshot.close();
|
|
359
441
|
}
|
|
360
442
|
}
|