OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-identity commits / 379f3c9

anproto: pin the timestamp to 13 digits — a greedy parse was eating the hash
`open()` split the signed message with `/^\d+/`. The message is `timestamp + base64(sha256)`, so whenever the hash's first character happened to BE a digit the timestamp swallowed it and the hash came back 43 characters, failing as "hash mismatch". Base64's first character is uniform over 64 symbols, 10 of which are digits. Measured: 59 failures in 300 sign/verify round-trips before, 0 in 500 after. ⚠ It is DETERMINISTIC PER CHALLENGE, not flaky — the same challenge always hashes the same way, so a given challenge either always worked or never did. That is what let it survive: it looks like a bad client, not a broken parser. The canonical `anproto` package has always used `/^(\d{13})(.+)$/` (lib/an.js:119). This file is a COPY that drifted, which is why pear-git-v3 never saw it — pgv3 depends on the package (`import { an } from 'anproto'`), not on this. Its importers are pear-git, hypernote and hyper-booking. Wire-compatible: the envelope bytes are unchanged, only the parse. A fixed verifier accepts signatures from fixed and unfixed signers alike, because signing was never wrong. anproto-client.js had the identical bug and is pinned the same way. ⚠ I over-claimed when I found this: I asserted a production login failure rate from a unit round-trip, and that was wrong — the forge does not use this code. Corrected before it went anywhere. ⚠ Six-plus copies of this wire format exist in the monorepo and three still carry the greedy parse (pear-git-v3's SERVED browser client, wirecore's lib/core and its pear-app copy). Consolidating on the package is the real fix; this is the bugfix for the copy hyper-identity owns. 124/124, 348/348. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
379f3c90cc7c3e4c16c7b1ae75d2bcd22bf9360b
by plankatron <fleeky@gmail.com> · 2026-09-06T22:23:31.000Z

Changed files (3) +53 -2

lib/anproto-client.js +3 -1
……@@ -125,7 +125,9 @@ export const ANProtoClient = {
125125 }
126126
127127 const bodyStr = new TextDecoder().decode(body)
128 const tsMatch = bodyStr.match(/^(\d+)/)
128 // ⛔ 13 digits exactly — see lib/anproto.js. A greedy match eats the hash's leading character
129 // whenever it is a digit, which is ~1 in 6 messages.
130 const tsMatch = bodyStr.match(/^(\d{13})/)
129131 const timestamp = tsMatch ? parseInt(tsMatch[0], 10) : null
130132 const hash = tsMatch ? bodyStr.substring(tsMatch[0].length) : bodyStr
131133
lib/anproto.js +14 -1
……@@ -197,7 +197,20 @@ export class ANProto {
197197 }
198198
199199 const messageStr = b4a.toString(originalMessage, 'utf8')
200 const timestampMatch = messageStr.match(/^\d+/)
200 // ⛔ EXACTLY 13 digits, never `\d+`.
201 //
202 // The message is `timestamp + base64(sha256)`. A greedy digit match swallows the hash's first
203 // character whenever it happens to BE a digit — 10 of base64's 64 symbols, so ~1 in 6 messages
204 // came back with a 43-character hash and failed as "hash mismatch". Measured 59/300 before this
205 // line was pinned.
206 //
207 // 13 is what the canonical `anproto` package uses (lib/an.js:119, `/^(\d{13})(.+)$/`), which is
208 // why pear-git-v3 never saw this: it depends on that package, and this file is a separate copy
209 // that had drifted. Matching it is convergence, not a new rule.
210 //
211 // ⚠ Wire-compatible: the envelope bytes are unchanged, only the parse. A fixed verifier accepts
212 // signatures from fixed and unfixed signers alike — signing was never wrong.
213 const timestampMatch = messageStr.match(/^\d{13}/)
201214
202215 if (!timestampMatch) {
203216 throw new Error('Invalid message format (no timestamp)')
test/anproto.test.js +36 -0
……@@ -207,3 +207,39 @@ test('createMessage with string content', (t) => {
207207 t.ok(verified.verified)
208208 t.is(verified.content, 'plain string content')
209209})
210
211// ⛔ REGRESSION: the timestamp match must be EXACTLY 13 digits.
212//
213// The signed message is `timestamp + base64(sha256(challenge))`. A greedy `\d+` swallows the
214// hash's first character whenever it is a digit — 10 of base64's 64 symbols — so roughly one in
215// six verifications failed as "hash mismatch". Measured 59/300 before the fix, 0/500 after.
216//
217// It is deterministic per challenge, not flaky: the same challenge always produces the same hash,
218// so a given challenge either always worked or never did. That is what made it survive — it looks
219// like a bad client rather than a broken parser.
220//
221// The canonical `anproto` package (lib/an.js:119) has always used `/^(\d{13})(.+)$/`, which is why
222// pear-git-v3 was unaffected: it depends on that package, and this file is a copy that drifted.
223test('a hash beginning with a digit still verifies', (t) => {
224 const kp = ANProto.gen()
225
226 // Find a challenge whose hash starts with a digit — the case that used to fail.
227 let challenge = null
228 for (let i = 0; i < 200 && !challenge; i++) {
229 const c = 'challenge-' + i
230 if (/^[0-9]/.test(ANProto.hash(c))) challenge = c
231 }
232 t.ok(challenge, 'found one — they are ~15% of all challenges')
233
234 const r = ANProto.verify(challenge, ANProto.sign(challenge, kp))
235 t.ok(r.valid, 'verifies: ' + (r.error || 'ok'))
236 t.is(ANProto.open(ANProto.sign(challenge, kp)).hash, ANProto.hash(challenge),
237 'and the parsed hash is whole, not one character short')
238})
239
240test('the timestamp is parsed as exactly 13 digits', (t) => {
241 const kp = ANProto.gen()
242 const opened = ANProto.open(ANProto.sign('x', kp))
243 t.is(String(opened.timestamp).length, 13, 'never 14 — a 14th digit means it ate the hash')
244 t.is(opened.hash.length, 44, 'base64 of sha256 is always 44 characters')
245})