ticketlens 0.38.26 → 0.38.28
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/package.json
CHANGED
|
@@ -12,7 +12,12 @@ import { readMachineSecret, readOrCreateMachineSecret, signHmac, verifyHmac } fr
|
|
|
12
12
|
const TOKEN_FILE = 'cli-token.json';
|
|
13
13
|
// Distinct from license.mjs's LICENSE_HMAC_SALT so a license.json signature
|
|
14
14
|
// can never be replayed as a valid cli-token.json tier signature, or vice versa.
|
|
15
|
-
const CLI_TOKEN_HMAC_SALT = 'tl-tok-v1';
|
|
15
|
+
export const CLI_TOKEN_HMAC_SALT = 'tl-tok-v1';
|
|
16
|
+
// Shorter than license.mjs's 30-day GRACE_DAYS — a team seat being removed is a
|
|
17
|
+
// security action by the manager, not a billing lapse, so the offline window a
|
|
18
|
+
// revoked member's stale local access can survive on should be tighter.
|
|
19
|
+
const TEAM_TIER_GRACE_DAYS = 7;
|
|
20
|
+
const MS_PER_DAY = 86400000;
|
|
16
21
|
|
|
17
22
|
export function cliTokenPath(configDir = DEFAULT_CONFIG_DIR) {
|
|
18
23
|
return join(configDir, TOKEN_FILE);
|
|
@@ -34,34 +39,99 @@ export function readCliToken(configDir = DEFAULT_CONFIG_DIR) {
|
|
|
34
39
|
// Lets team-invited members get Pro-equivalent CLI access without a personal
|
|
35
40
|
// TL- key, mirroring what the manager already pays for.
|
|
36
41
|
//
|
|
37
|
-
// Signed with a machine-bound HMAC so `tier` can't be
|
|
38
|
-
// to escalate entitlement. A missing or
|
|
39
|
-
// tier at all (falls back to free) rather
|
|
40
|
-
// next `tl login`/`tl sync`, both of which
|
|
42
|
+
// Signed with a machine-bound HMAC so `tier`/`syncedAt`/`freshMark` can't be
|
|
43
|
+
// hand-edited in the file to escalate or extend entitlement. A missing or
|
|
44
|
+
// invalid signature is treated as no tier at all (falls back to free) rather
|
|
45
|
+
// than trusted — self-heals on the next `tl login`/`tl sync`, both of which
|
|
46
|
+
// re-write and re-sign it. A file with no `syncedAt` (written before this
|
|
47
|
+
// grace-period check existed) is rejected outright for the same reason,
|
|
48
|
+
// rather than treated as permanently fresh.
|
|
49
|
+
//
|
|
50
|
+
// TEAM_TIER_GRACE_DAYS bounds how long a synced team-seat tier stays valid without
|
|
51
|
+
// a fresh login/sync — mirrors license.mjs's offline GRACE_DAYS so a team member
|
|
52
|
+
// removed server-side can't keep local access indefinitely just by never syncing.
|
|
53
|
+
//
|
|
54
|
+
// `freshMark` is a monotonic high-water mark of the latest wall-clock time this
|
|
55
|
+
// file has ever been read at without a detected rollback. It is ratcheted forward
|
|
56
|
+
// (never backward) on every honest read and is rejected if the clock now reads
|
|
57
|
+
// earlier than it — closing the realistic attack of rolling the OS clock back
|
|
58
|
+
// once, after a team seat is revoked, to keep `syncedAt` looking fresh forever.
|
|
59
|
+
// It does NOT close every variant: an attacker who fakes the clock from the very
|
|
60
|
+
// first read after a real sync, before any honest freshMark is ever recorded past
|
|
61
|
+
// that point, is not caught — that is an inherent limit of any offline,
|
|
62
|
+
// network-free grace period (the same residual gap license.json's GRACE_DAYS has
|
|
63
|
+
// always had), not something this mechanism claims to solve.
|
|
41
64
|
export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
|
|
42
65
|
const p = cliTokenPath(configDir);
|
|
43
66
|
if (!existsSync(p)) return null;
|
|
44
67
|
try {
|
|
45
68
|
const data = JSON.parse(readFileSync(p, 'utf8'));
|
|
46
|
-
if (
|
|
69
|
+
if (
|
|
70
|
+
typeof data.tier !== 'string' ||
|
|
71
|
+
typeof data.tierSig !== 'string' ||
|
|
72
|
+
typeof data.syncedAt !== 'string' ||
|
|
73
|
+
typeof data.freshMark !== 'string'
|
|
74
|
+
) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
47
77
|
const secret = readMachineSecret(configDir);
|
|
48
78
|
if (!secret) return null;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
79
|
+
const signed = { tier: data.tier, syncedAt: data.syncedAt, freshMark: data.freshMark };
|
|
80
|
+
if (!verifyHmac(data.tierSig, signed, `${CLI_TOKEN_HMAC_SALT}:${secret}`)) return null;
|
|
81
|
+
|
|
82
|
+
// An unparseable syncedAt gives NaN — rejected on its own line rather than
|
|
83
|
+
// leaning on NaN quietly failing the grace-period comparison below.
|
|
84
|
+
const syncedAtMs = new Date(data.syncedAt).getTime();
|
|
85
|
+
if (Number.isNaN(syncedAtMs)) return null;
|
|
86
|
+
|
|
87
|
+
const daysSinceSync = (Date.now() - syncedAtMs) / MS_PER_DAY;
|
|
88
|
+
if (daysSinceSync > TEAM_TIER_GRACE_DAYS) return null;
|
|
89
|
+
|
|
90
|
+
const freshMarkMs = new Date(data.freshMark).getTime();
|
|
91
|
+
if (Number.isNaN(freshMarkMs)) return null;
|
|
92
|
+
|
|
93
|
+
const nowMs = Date.now();
|
|
94
|
+
if (nowMs < freshMarkMs) return null;
|
|
95
|
+
|
|
96
|
+
if (nowMs > freshMarkMs) {
|
|
97
|
+
// Best-effort ratchet — wrapped so a disk-write failure (full disk,
|
|
98
|
+
// permissions changed mid-session) can't turn an otherwise-valid read
|
|
99
|
+
// into a null; the mark simply stays where it was for next time.
|
|
100
|
+
try {
|
|
101
|
+
writeFreshMark(p, data, secret, nowMs);
|
|
102
|
+
} catch {
|
|
103
|
+
// ignore — ratcheting forward is an optimization, not a correctness requirement
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return data.tier;
|
|
52
108
|
} catch {
|
|
53
109
|
return null;
|
|
54
110
|
}
|
|
55
111
|
}
|
|
56
112
|
|
|
113
|
+
function writeFreshMark(p, data, secret, nowMs) {
|
|
114
|
+
const freshMark = new Date(nowMs).toISOString();
|
|
115
|
+
const signed = { tier: data.tier, syncedAt: data.syncedAt, freshMark };
|
|
116
|
+
const tierSig = signHmac(signed, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
|
|
117
|
+
const payload = { token: data.token, ...signed, tierSig };
|
|
118
|
+
writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
119
|
+
chmodSync(p, 0o600);
|
|
120
|
+
}
|
|
121
|
+
|
|
57
122
|
export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR, tier = undefined) {
|
|
58
123
|
mkdirSync(configDir, { recursive: true });
|
|
59
124
|
const p = cliTokenPath(configDir);
|
|
60
125
|
let payload = { token };
|
|
61
126
|
if (tier !== undefined) {
|
|
62
127
|
const secret = readOrCreateMachineSecret(configDir);
|
|
63
|
-
const
|
|
64
|
-
|
|
128
|
+
const syncedAt = new Date().toISOString();
|
|
129
|
+
// A real login/sync is itself a trusted checkpoint (it round-tripped to the
|
|
130
|
+
// server), so freshMark resets to "now" here rather than only ratcheting —
|
|
131
|
+
// unlike a local read, this is not attacker-reachable without valid credentials.
|
|
132
|
+
const freshMark = syncedAt;
|
|
133
|
+
const tierSig = signHmac({ tier, syncedAt, freshMark }, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
|
|
134
|
+
payload = { token, tier, syncedAt, freshMark, tierSig };
|
|
65
135
|
}
|
|
66
136
|
writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
|
|
67
137
|
chmodSync(p, 0o600);
|
|
@@ -22,6 +22,16 @@ export const getApiBase = () => apiBase();
|
|
|
22
22
|
// Strip the api. subdomain to get the console base URL (e.g. api.ticketlens.app → ticketlens.app)
|
|
23
23
|
export const getConsoleBase = () => getApiBase().replace('://api.', '://');
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* Server wins on tier same as every other field this file syncs — a missing or
|
|
27
|
+
* non-string `tier` in the response must clear a stale locally-elevated tier
|
|
28
|
+
* rather than leave it untouched, so a downgrade (or a malformed response) can
|
|
29
|
+
* never be silently ignored.
|
|
30
|
+
*/
|
|
31
|
+
function resolveTierFromResponse(json) {
|
|
32
|
+
return typeof json?.tier === 'string' ? json.tier : 'free';
|
|
33
|
+
}
|
|
34
|
+
|
|
25
35
|
/**
|
|
26
36
|
* Convert a server profile (snake_case) to the CLI profile shape (camelCase).
|
|
27
37
|
* Returns { name, profileData } — no credential fields.
|
|
@@ -90,7 +100,7 @@ export async function syncProfiles({
|
|
|
90
100
|
let json;
|
|
91
101
|
try { json = await res.json(); } catch { return { error: 'invalid-json' }; }
|
|
92
102
|
|
|
93
|
-
|
|
103
|
+
saveCliTokenTier(resolveTierFromResponse(json), configDir);
|
|
94
104
|
saveTeams(Array.isArray(json?.teams) ? json.teams : [], configDir);
|
|
95
105
|
|
|
96
106
|
const remoteProfiles = Array.isArray(json?.profiles) ? json.profiles : [];
|
|
@@ -159,6 +169,14 @@ export async function checkTeamMembershipUpdate({ configDir = DEFAULT_CONFIG_DIR
|
|
|
159
169
|
let json;
|
|
160
170
|
try { json = await res.json(); } catch { return { updated: false }; }
|
|
161
171
|
|
|
172
|
+
// Refresh the signed tier from the same payload this check already fetches —
|
|
173
|
+
// this fires on every `ticketlens TICKET-123` fetch, so it shrinks the real-world
|
|
174
|
+
// exposure window down from the grace period (cli-auth.mjs's TEAM_TIER_GRACE_DAYS)
|
|
175
|
+
// to "next fetch" for anyone who ever runs one. The grace period remains the
|
|
176
|
+
// backstop for local-only commands (history, schedule --local, review) that never
|
|
177
|
+
// reach this function at all.
|
|
178
|
+
saveCliTokenTier(resolveTierFromResponse(json), configDir);
|
|
179
|
+
|
|
162
180
|
// Sanitized immediately on receipt — remoteTeams feeds both the banner
|
|
163
181
|
// text below (built before saveTeams' own sanitization would ever run)
|
|
164
182
|
// and the persisted cache, so both must see the cleaned name.
|