ticketlens 0.38.27 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ticketlens",
3
- "version": "0.38.27",
3
+ "version": "0.38.28",
4
4
  "description": "Jira CLI for developers — fetch ticket context, triage your queue, and stop tab-switching. Zero dependencies, all local.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,27 +39,44 @@ export function readCliToken(configDir = DEFAULT_CONFIG_DIR) {
39
39
  // Lets team-invited members get Pro-equivalent CLI access without a personal
40
40
  // TL- key, mirroring what the manager already pays for.
41
41
  //
42
- // Signed with a machine-bound HMAC so `tier`/`syncedAt` can't be hand-edited in
43
- // the file to escalate or extend entitlement. A missing or invalid signature is
44
- // treated as no tier at all (falls back to free) rather than trusted — self-heals
45
- // on the next `tl login`/`tl sync`, both of which re-write and re-sign it. A file
46
- // with no `syncedAt` (written before this grace-period check existed) is rejected
47
- // outright for the same reason, rather than treated as permanently fresh.
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.
48
49
  //
49
50
  // TEAM_TIER_GRACE_DAYS bounds how long a synced team-seat tier stays valid without
50
51
  // a fresh login/sync — mirrors license.mjs's offline GRACE_DAYS so a team member
51
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.
52
64
  export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
53
65
  const p = cliTokenPath(configDir);
54
66
  if (!existsSync(p)) return null;
55
67
  try {
56
68
  const data = JSON.parse(readFileSync(p, 'utf8'));
57
- if (typeof data.tier !== 'string' || typeof data.tierSig !== 'string' || typeof data.syncedAt !== 'string') {
69
+ if (
70
+ typeof data.tier !== 'string' ||
71
+ typeof data.tierSig !== 'string' ||
72
+ typeof data.syncedAt !== 'string' ||
73
+ typeof data.freshMark !== 'string'
74
+ ) {
58
75
  return null;
59
76
  }
60
77
  const secret = readMachineSecret(configDir);
61
78
  if (!secret) return null;
62
- const signed = { tier: data.tier, syncedAt: data.syncedAt };
79
+ const signed = { tier: data.tier, syncedAt: data.syncedAt, freshMark: data.freshMark };
63
80
  if (!verifyHmac(data.tierSig, signed, `${CLI_TOKEN_HMAC_SALT}:${secret}`)) return null;
64
81
 
65
82
  // An unparseable syncedAt gives NaN — rejected on its own line rather than
@@ -70,12 +87,38 @@ export function readCliTokenTier(configDir = DEFAULT_CONFIG_DIR) {
70
87
  const daysSinceSync = (Date.now() - syncedAtMs) / MS_PER_DAY;
71
88
  if (daysSinceSync > TEAM_TIER_GRACE_DAYS) return null;
72
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
+
73
107
  return data.tier;
74
108
  } catch {
75
109
  return null;
76
110
  }
77
111
  }
78
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
+
79
122
  export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR, tier = undefined) {
80
123
  mkdirSync(configDir, { recursive: true });
81
124
  const p = cliTokenPath(configDir);
@@ -83,8 +126,12 @@ export function saveCliToken(token, configDir = DEFAULT_CONFIG_DIR, tier = undef
83
126
  if (tier !== undefined) {
84
127
  const secret = readOrCreateMachineSecret(configDir);
85
128
  const syncedAt = new Date().toISOString();
86
- const tierSig = signHmac({ tier, syncedAt }, `${CLI_TOKEN_HMAC_SALT}:${secret}`);
87
- payload = { token, tier, syncedAt, tierSig };
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 };
88
135
  }
89
136
  writeFileSync(p, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf8', mode: 0o600 });
90
137
  chmodSync(p, 0o600);