ftown 0.20.9 → 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 +1 -0
- package/dist/identity-reset.js +31 -5
- package/dist/identity-reset.js.map +1 -1
- package/dist/index.js +184 -288
- 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 +0 -13
- package/dist/revocation-gating.js +0 -16
- package/dist/revocation-gating.js.map +1 -1
- 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 -23
- package/dist/security/bridge/policy.js +214 -146
- package/dist/security/bridge/policy.js.map +1 -1
- package/dist/security/bridge/security.d.ts +84 -33
- package/dist/security/bridge/security.js +240 -166
- 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 +3 -2
- package/dist/bridge-auth.d.ts +0 -31
- package/dist/bridge-auth.js +0 -70
- package/dist/bridge-auth.js.map +0 -1
- package/dist/pairing-client.d.ts +0 -62
- package/dist/pairing-client.js +0 -469
- 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 -101
- 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/bridge/revocation.d.ts +0 -14
- package/dist/security/bridge/revocation.js +0 -40
- package/dist/security/bridge/revocation.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,18 +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
|
-
/** Reads <securityDir>/policy.json without locking or writing; returns the pinned account checkpoint, or null when the file, the account section or parsing is absent/invalid. */
|
|
7
|
-
export declare function readPinnedAccountCheckpoint(securityDir: string): AccountCheckpoint | null;
|
|
8
7
|
/** Identity comparison matters: request JSON cannot manufacture this recovery capability. */
|
|
9
8
|
export declare const LOCAL_RECOVERY_AUTHORITY: LocalRecoveryAuthority;
|
|
10
9
|
export interface PolicyOptions {
|
|
11
10
|
now?: () => number;
|
|
12
|
-
monotonicNow?: () => number;
|
|
13
11
|
relayUrl?: string;
|
|
14
12
|
localOrigin?: string;
|
|
15
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
|
+
}
|
|
16
25
|
/** Bridge-owned authorization. Directory entries and caller-claimed permissions never enter this store. */
|
|
17
26
|
export declare class BridgePolicyStore {
|
|
18
27
|
readonly bridgeId: string;
|
|
@@ -20,10 +29,22 @@ export declare class BridgePolicyStore {
|
|
|
20
29
|
private readonly options;
|
|
21
30
|
private readonly snapshot;
|
|
22
31
|
private readonly now;
|
|
23
|
-
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;
|
|
24
35
|
constructor(dataDir: string, bridgeId: string, bridge: EndpointPublic, options?: PolicyOptions);
|
|
25
36
|
private check;
|
|
26
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;
|
|
27
48
|
private principalFrom;
|
|
28
49
|
private authorize;
|
|
29
50
|
principal(endpointId: string): Promise<VerifiedPrincipal>;
|
|
@@ -34,21 +55,19 @@ export declare class BridgePolicyStore {
|
|
|
34
55
|
recipients(): Promise<ApprovedEndpoint[]>;
|
|
35
56
|
revoke(actor: VerifiedPrincipal, endpointId: string): Promise<void>;
|
|
36
57
|
provisionLocalEndpoint(endpoint: EndpointPublic): Promise<void>;
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
revoked: string[];
|
|
52
|
-
}>;
|
|
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;
|
|
53
72
|
close(): Promise<void>;
|
|
54
73
|
}
|
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
import { legacyDateTimeSchema } from '../../zod-compat.js';
|
|
2
2
|
import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
3
|
-
import {
|
|
4
|
-
import { join } from 'node:path';
|
|
3
|
+
import { mkdir, open, rename, rm } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { BridgeSecurityError } from './errors.js';
|
|
7
7
|
import { approvedSchema, displayLabel, endpointSchema, permissionSchema } from './metadata.js';
|
|
8
8
|
import { AtomicSnapshot } from './snapshot.js';
|
|
9
|
-
import {
|
|
10
|
-
import { AccountConnectivity, storedAccountSchema } from './account-state.js';
|
|
9
|
+
import { LEDGER_LIMITS } from '../ledger/protocol.js';
|
|
11
10
|
const FIVE_MINUTES = 300_000;
|
|
12
11
|
const MAX_ENDPOINTS = 1000;
|
|
13
12
|
const digest = (secret) => createHash('sha256').update(secret).digest('hex');
|
|
14
13
|
const invitationSchema = z.object({ id: z.string().guid(), secretHash: z.string().regex(/^[a-f0-9]{64}$/),
|
|
15
14
|
label: z.string().max(100), permissions: z.array(permissionSchema).max(3), expiresAt: legacyDateTimeSchema,
|
|
16
|
-
consumedBy: z.string().nullable(), provenance: z.
|
|
17
|
-
/** 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). */
|
|
18
19
|
export function migrateLegacyPolicy(raw) {
|
|
19
20
|
if (typeof raw !== 'object' || raw === null || Array.isArray(raw))
|
|
20
21
|
return raw;
|
|
21
22
|
const { mcp: _mcp, ...rest } = raw;
|
|
23
|
+
if (rest.account === null)
|
|
24
|
+
delete rest.account;
|
|
22
25
|
const isMcpDevice = (device) => typeof device === 'object' && device !== null
|
|
23
26
|
&& device.endpoint?.kind === 'mcp';
|
|
24
27
|
const dropped = new Set();
|
|
@@ -41,8 +44,8 @@ const policySchema = z.preprocess(migrateLegacyPolicy, z.object({ version: z.lit
|
|
|
41
44
|
invitations: z.array(invitationSchema).max(256),
|
|
42
45
|
// Frozen: earlier releases require this key, so a downgrade must still parse what we write.
|
|
43
46
|
mcp: z.array(z.never()).max(0).default([]),
|
|
44
|
-
account:
|
|
45
|
-
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({}),
|
|
46
49
|
}).strict().superRefine((state, context) => {
|
|
47
50
|
if (new Set(state.devices.map(device => device.endpoint.id)).size !== state.devices.length
|
|
48
51
|
|| state.devices.some(device => device.endpoint.ownerId !== state.ownerId
|
|
@@ -51,21 +54,61 @@ const policySchema = z.preprocess(migrateLegacyPolicy, z.object({ version: z.lit
|
|
|
51
54
|
context.addIssue({ code: z.ZodIssueCode.custom, message: 'Invalid policy relationship' });
|
|
52
55
|
}
|
|
53
56
|
}));
|
|
54
|
-
/**
|
|
55
|
-
export
|
|
57
|
+
/** Identity comparison matters: request JSON cannot manufacture this recovery capability. */
|
|
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
|
+
}
|
|
56
77
|
try {
|
|
57
|
-
const
|
|
58
|
-
if (
|
|
59
|
-
return
|
|
60
|
-
const
|
|
61
|
-
|
|
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;
|
|
62
88
|
}
|
|
63
89
|
catch {
|
|
64
|
-
return
|
|
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');
|
|
65
110
|
}
|
|
66
111
|
}
|
|
67
|
-
/** Identity comparison matters: request JSON cannot manufacture this recovery capability. */
|
|
68
|
-
export const LOCAL_RECOVERY_AUTHORITY = Object.freeze({ source: 'os-local-recovery' });
|
|
69
112
|
/** Bridge-owned authorization. Directory entries and caller-claimed permissions never enter this store. */
|
|
70
113
|
export class BridgePolicyStore {
|
|
71
114
|
bridgeId;
|
|
@@ -73,7 +116,9 @@ export class BridgePolicyStore {
|
|
|
73
116
|
options;
|
|
74
117
|
snapshot;
|
|
75
118
|
now;
|
|
76
|
-
|
|
119
|
+
clockFile;
|
|
120
|
+
/** Account mode (enableLedger): null for Solo and --local, whose devices come only from invitations and the local CLI. */
|
|
121
|
+
ledger = null;
|
|
77
122
|
constructor(dataDir, bridgeId, bridge, options = {}) {
|
|
78
123
|
this.bridgeId = bridgeId;
|
|
79
124
|
this.bridge = bridge;
|
|
@@ -82,10 +127,10 @@ export class BridgePolicyStore {
|
|
|
82
127
|
if (bridge.kind !== 'bridge')
|
|
83
128
|
throw new BridgeSecurityError('wrong_context');
|
|
84
129
|
this.now = options.now ?? Date.now;
|
|
85
|
-
this.
|
|
130
|
+
this.clockFile = join(dataDir, 'security', 'ledger-clock.json');
|
|
86
131
|
this.snapshot = new AtomicSnapshot(join(dataDir, 'security', 'policy.json'), policySchema, () => ({
|
|
87
132
|
version: 1, ownerId: bridge.ownerId, bridgeId, bridgeEndpointId: bridge.id, generation: 0,
|
|
88
|
-
devices: [], invitations: [], mcp: [],
|
|
133
|
+
devices: [], invitations: [], mcp: [], provenance: {},
|
|
89
134
|
}));
|
|
90
135
|
}
|
|
91
136
|
check(state) {
|
|
@@ -95,23 +140,89 @@ export class BridgePolicyStore {
|
|
|
95
140
|
}
|
|
96
141
|
active(state, endpointId) {
|
|
97
142
|
this.check(state);
|
|
98
|
-
const account = state.account;
|
|
99
|
-
const member = account?.checkpoint.devices.find(item => item.endpoint.id === endpointId);
|
|
100
|
-
if (member) {
|
|
101
|
-
this.connectivity.check(account);
|
|
102
|
-
const own = account.checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
|
|
103
|
-
if (!own || own.endpoint.id !== this.bridge.id || own.endpoint.publicKey !== this.bridge.publicKey)
|
|
104
|
-
throw new BridgeSecurityError('account_bridge_revoked');
|
|
105
|
-
}
|
|
106
|
-
else if (account?.checkpoint.revokedDeviceIds.includes(endpointId))
|
|
107
|
-
throw new BridgeSecurityError('account_device_revoked');
|
|
108
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');
|
|
109
149
|
if (!record)
|
|
110
150
|
throw new BridgeSecurityError('approval_required');
|
|
111
151
|
if (record.expiresAt && Date.parse(record.expiresAt) <= this.now())
|
|
112
152
|
throw new BridgeSecurityError('expired');
|
|
113
153
|
return record;
|
|
114
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
|
+
}
|
|
115
226
|
principalFrom(state, record) {
|
|
116
227
|
return { endpoint: record.endpoint, bridgeId: this.bridgeId, permissions: record.permissions,
|
|
117
228
|
policyGeneration: state.generation, grantId: record.grantId };
|
|
@@ -149,16 +260,15 @@ export class BridgePolicyStore {
|
|
|
149
260
|
const expiresAt = new Date(this.now() + FIVE_MINUTES).toISOString();
|
|
150
261
|
await this.snapshot.transact(state => {
|
|
151
262
|
this.check(state);
|
|
152
|
-
|
|
263
|
+
// Account mode: browsers join only through the ledger, whoever asks.
|
|
264
|
+
this.refuseInAccountMode();
|
|
265
|
+
if (actor !== LOCAL_RECOVERY_AUTHORITY)
|
|
153
266
|
this.authorize(state, actor, 'admin');
|
|
154
|
-
if (state.account && actor.endpoint.kind !== 'local-cli')
|
|
155
|
-
throw new BridgeSecurityError('account_approval_required');
|
|
156
|
-
}
|
|
157
267
|
state.invitations = state.invitations.filter(invitation => Date.parse(invitation.expiresAt) > this.now());
|
|
158
268
|
if (state.invitations.length >= 256)
|
|
159
269
|
throw new BridgeSecurityError('capacity_exceeded');
|
|
160
270
|
state.invitations.push({ id: invitationId, secretHash: digest(secret), label: displayLabel(options.label),
|
|
161
|
-
permissions: ['read', 'control', 'admin'], expiresAt, consumedBy: null, provenance:
|
|
271
|
+
permissions: ['read', 'control', 'admin'], expiresAt, consumedBy: null, provenance: 'legacy' });
|
|
162
272
|
});
|
|
163
273
|
return { version: 1, invitationId, ownerId: this.bridge.ownerId, bridgeId: this.bridgeId, bridge: this.bridge,
|
|
164
274
|
secret, expiresAt, relayUrl: this.options.relayUrl ?? null, localOrigin: this.options.localOrigin ?? null };
|
|
@@ -176,18 +286,13 @@ export class BridgePolicyStore {
|
|
|
176
286
|
throw new BridgeSecurityError('invitation_invalid');
|
|
177
287
|
return this.snapshot.transact(state => {
|
|
178
288
|
this.check(state);
|
|
289
|
+
this.refuseInAccountMode();
|
|
179
290
|
const invitation = state.invitations.find(item => item.id === message.invitationId);
|
|
180
291
|
if (!invitation || !timingSafeEqual(Buffer.from(invitation.secretHash), Buffer.from(digest(message.invitationSecret)))) {
|
|
181
292
|
throw new BridgeSecurityError('invitation_invalid');
|
|
182
293
|
}
|
|
183
294
|
if (Date.parse(invitation.expiresAt) <= this.now())
|
|
184
295
|
throw new BridgeSecurityError('invitation_expired');
|
|
185
|
-
if (state.account && invitation.provenance !== 'local-recovery')
|
|
186
|
-
throw new BridgeSecurityError('invitation_invalid');
|
|
187
|
-
// Independent recovery must never shadow an account membership or revive its tombstone.
|
|
188
|
-
if (state.account && (state.account.checkpoint.devices.some(item => item.endpoint.id === candidate.id)
|
|
189
|
-
|| state.account.checkpoint.revokedDeviceIds.includes(candidate.id)))
|
|
190
|
-
throw new BridgeSecurityError('account_approval_required');
|
|
191
296
|
if (invitation.consumedBy) {
|
|
192
297
|
if (invitation.consumedBy !== candidate.id)
|
|
193
298
|
throw new BridgeSecurityError('invitation_consumed');
|
|
@@ -214,7 +319,7 @@ export class BridgePolicyStore {
|
|
|
214
319
|
const provenance = state.provenance[device.endpoint.id];
|
|
215
320
|
if (provenance === 'account')
|
|
216
321
|
return { ...device, source: 'account' };
|
|
217
|
-
if (provenance === 'local-cli'
|
|
322
|
+
if (provenance === 'local-cli')
|
|
218
323
|
return { ...device, source: 'local' };
|
|
219
324
|
return { ...device };
|
|
220
325
|
});
|
|
@@ -236,8 +341,10 @@ export class BridgePolicyStore {
|
|
|
236
341
|
async revoke(actor, endpointId) {
|
|
237
342
|
await this.snapshot.transact(state => {
|
|
238
343
|
this.authorize(state, actor, 'admin');
|
|
239
|
-
|
|
240
|
-
|
|
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');
|
|
241
348
|
state.devices = state.devices.filter(device => device.endpoint.id !== endpointId);
|
|
242
349
|
delete state.provenance[endpointId];
|
|
243
350
|
state.generation++;
|
|
@@ -259,116 +366,77 @@ export class BridgePolicyStore {
|
|
|
259
366
|
state.provenance[candidate.id] = 'local-cli';
|
|
260
367
|
});
|
|
261
368
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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() };
|
|
267
374
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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 => {
|
|
271
394
|
this.check(state);
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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];
|
|
279
405
|
return false;
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
this.check(state);
|
|
287
|
-
if (state.account) {
|
|
288
|
-
if (state.account.checkpoint.rootId !== trust.checkpoint.rootId)
|
|
289
|
-
throw new BridgeSecurityError('account_trust_rollback');
|
|
290
|
-
return;
|
|
291
|
-
}
|
|
292
|
-
const checkpoint = await verifyAccountHistory(trust.history, trust.checkpoint, this.now());
|
|
293
|
-
this.requireOwnBridge(checkpoint);
|
|
294
|
-
this.projectAccount(state, { ...trust, checkpoint, lastServerReadAt: 0, lastObservedAt: 0 });
|
|
295
|
-
});
|
|
296
|
-
}
|
|
297
|
-
requireOwnBridge(checkpoint) {
|
|
298
|
-
const own = checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
|
|
299
|
-
if (checkpoint.ownerId !== this.bridge.ownerId || !own || own.endpoint.id !== this.bridge.id
|
|
300
|
-
|| own.endpoint.publicKey !== this.bridge.publicKey || own.endpoint.ownerId !== this.bridge.ownerId)
|
|
301
|
-
throw new BridgeSecurityError('wrong_context');
|
|
302
|
-
}
|
|
303
|
-
projectAccount(state, trust) {
|
|
304
|
-
const previous = state.devices.filter(item => item.endpoint.kind === 'browser');
|
|
305
|
-
const first = state.account === null;
|
|
306
|
-
const unchanged = state.account?.checkpoint.head === trust.checkpoint.head;
|
|
307
|
-
state.devices = state.devices.filter(item => item.endpoint.kind !== 'browser'
|
|
308
|
-
|| (!first && state.provenance[item.endpoint.id] === 'local-recovery'
|
|
309
|
-
&& !trust.checkpoint.devices.some(member => member.endpoint.id === item.endpoint.id)
|
|
310
|
-
&& !trust.checkpoint.revokedDeviceIds.includes(item.endpoint.id)));
|
|
311
|
-
if (first)
|
|
312
|
-
state.invitations = [];
|
|
313
|
-
for (const entry of previous)
|
|
314
|
-
if (!state.devices.some(item => item.endpoint.id === entry.endpoint.id))
|
|
315
|
-
delete state.provenance[entry.endpoint.id];
|
|
316
|
-
const own = trust.checkpoint.bridges.find(item => item.bridgeId === this.bridgeId);
|
|
317
|
-
if (own?.endpoint.id === this.bridge.id && own.endpoint.publicKey === this.bridge.publicKey) {
|
|
318
|
-
for (const member of trust.checkpoint.devices) {
|
|
319
|
-
if (state.devices.some(item => item.endpoint.id === member.endpoint.id))
|
|
320
|
-
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');
|
|
321
412
|
state.devices.push({ endpoint: member.endpoint, label: displayLabel(member.label), permissions: ['read', 'control', 'admin'],
|
|
322
|
-
approvedAt: member.approvedAt, grantId: null, expiresAt: null });
|
|
413
|
+
approvedAt: new Date(member.approvedAt).toISOString(), grantId: null, expiresAt: null });
|
|
323
414
|
state.provenance[member.endpoint.id] = 'account';
|
|
324
415
|
}
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
+
}));
|
|
330
421
|
}
|
|
331
|
-
/**
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
throw new BridgeSecurityError('wrong_context');
|
|
345
|
-
if (adoption) {
|
|
346
|
-
const head = adoption.head;
|
|
347
|
-
if (head.rootId !== checkpoint.rootId || head.revision !== checkpoint.revision || head.head !== checkpoint.head)
|
|
348
|
-
throw new BridgeSecurityError('account_trust_conflict');
|
|
349
|
-
this.requireOwnBridge(checkpoint);
|
|
350
|
-
const member = checkpoint.devices.find(item => item.endpoint.id === adoption.actor.endpoint.id);
|
|
351
|
-
if (!member || member.endpoint.publicKey !== adoption.actor.endpoint.publicKey || member.endpoint.ownerId !== adoption.actor.endpoint.ownerId) {
|
|
352
|
-
throw new BridgeSecurityError('account_device_revoked');
|
|
353
|
-
}
|
|
354
|
-
this.authorize(state, adoption.actor, 'admin');
|
|
355
|
-
}
|
|
356
|
-
const initial = state.account ?? { checkpoint, history, lastServerReadAt: 0, lastObservedAt: 0 };
|
|
357
|
-
const now = this.connectivity.observed(initial);
|
|
358
|
-
const trust = { checkpoint, history, lastServerReadAt: Math.max(initial.lastServerReadAt, now),
|
|
359
|
-
lastObservedAt: Math.max(initial.lastObservedAt, now) };
|
|
360
|
-
const revoked = this.projectAccount(state, trust);
|
|
361
|
-
return { head: { rootId: checkpoint.rootId, revision: checkpoint.revision, head: checkpoint.head }, revoked, trust };
|
|
362
|
-
});
|
|
363
|
-
this.connectivity.renew(result.trust);
|
|
364
|
-
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;
|
|
365
435
|
}
|
|
366
436
|
async close() {
|
|
367
|
-
|
|
368
|
-
this.
|
|
369
|
-
|
|
370
|
-
state.account.lastObservedAt = Math.max(state.account.lastObservedAt, this.connectivity.highwater());
|
|
371
|
-
});
|
|
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
|
+
}
|
|
372
440
|
await this.snapshot.close();
|
|
373
441
|
}
|
|
374
442
|
}
|