mirror of
https://github.com/PR0M3TH3AN/bitlogin.git
synced 2026-10-27 08:31:21 +00:00
Pin the derivation chain, and stop Dependabot grouping crypto majors
A Dependabot PR failed the build by bundling @noble/hashes 1.8->2.2, @noble/curves 1.9->2.2, @scure/base 1.2->2.2, @scure/bip39 1.6->2.2, TypeScript 5->7, Vite 6->8 and Vitest 3->4 into a single change. Production was never affected -- only that preview -- and the cause is real rather than flaky: noble v2 exports only ".js"-suffixed subpaths, while this code imports the extensionless "@noble/hashes/sha2" that 1.8 provides. Failing to compile was the LUCKY outcome. The tests could not have caught the dangerous version of this. Account capsules live on public relays, and the login name plus password derive the locator, signing scalar and capsule key deterministically -- so those outputs are a compatibility contract with every account that already exists. The crypto suite only checked self-consistency (same input, same output within one run), which stays green even if HKDF, base64url framing, BIP-39 seeding or JCS ordering all change. A grouped major that compiled would have shipped and locked every existing user out, with no server-side record to migrate them from. So this adds knownAnswer.test.ts, pinning the actual outputs of HKDF-Extract, HKDF-Expand, ScalarExpand (scalar, counter and resulting public key), BIP-39 phrase and seed, base64url, and JCS ordering. The vectors are captured from the shipping implementation, and the header says plainly what a failure means: not "update the expected value", but "this upgrade is account-breaking". dependabot.yml now groups only minor and patch updates. Majors are still proposed, individually, so each can be judged on its own. Majors of the derivation chain itself (@noble/*, @scure/*, hash-wasm, canonicalize) are ignored entirely -- that is deliberate work with the vectors green, not an automated PR waiting to be merged on a quiet afternoon. 172/172 tests, typecheck clean. Co-Authored-By: Claude Fable 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01CMr9Yt8SYmZcfgrg3CV2G1
This commit is contained in:
1 parent
7d2209d706
commit
6eb1d7d640
2 files changed
+133
-1
No files matched your search
+38
-1
@@ -1,3 +1,20 @@
|
||||
# Dependency updates.
|
||||
#
|
||||
# The original config grouped `*` with no update-type restriction, so its very
|
||||
# first PR bundled @noble/hashes 1.8->2.2, @noble/curves 1.9->2.2, @scure/base
|
||||
# 1.2->2.2, @scure/bip39 1.6->2.2, TypeScript 5->7, Vite 6->8 and Vitest 3->4
|
||||
# into one change. It failed the build outright (noble v2 dropped the
|
||||
# extensionless subpath exports this code imports), which was the lucky
|
||||
# outcome -- a grouped major that COMPILED would have been far worse.
|
||||
#
|
||||
# The reason to be careful here is specific to this project. Account capsules
|
||||
# live on public relays and the login name plus password derive the locator,
|
||||
# signing scalar and capsule key deterministically. Those outputs are a
|
||||
# compatibility contract with every account that already exists: change HKDF,
|
||||
# the scalar loop, base64url framing, BIP-39 seeding or JCS ordering and users
|
||||
# are locked out permanently, with no server-side record to migrate. See
|
||||
# packages/core/src/crypto/knownAnswer.test.ts, which pins those values so an
|
||||
# upgrade that alters them fails loudly instead of shipping.
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
@@ -6,9 +23,29 @@ updates:
|
||||
interval: weekly
|
||||
open-pull-requests-limit: 5
|
||||
groups:
|
||||
npm-security-and-maintenance:
|
||||
# Only non-breaking updates travel together. Majors still get proposed,
|
||||
# but individually, so each can be judged on its own merits instead of
|
||||
# arriving as one unreviewable pile.
|
||||
minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- minor
|
||||
- patch
|
||||
ignore:
|
||||
# The derivation chain. A major here is an account-compatibility event,
|
||||
# not a maintenance chore, so it should be a deliberate piece of work
|
||||
# with the known-answer vectors green -- never an automated PR waiting to
|
||||
# be merged on a quiet afternoon.
|
||||
- dependency-name: "@noble/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "@scure/*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "hash-wasm"
|
||||
update-types: ["version-update:semver-major"]
|
||||
- dependency-name: "canonicalize"
|
||||
update-types: ["version-update:semver-major"]
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
directory: /
|
||||
schedule:
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/*
|
||||
* Known-answer tests for the derivation chain.
|
||||
*
|
||||
* BitLogin accounts are encrypted capsules living on public relays, and the
|
||||
* login name plus password derive the locator, the signing scalar, and the
|
||||
* capsule key deterministically. Those outputs are therefore a COMPATIBILITY
|
||||
* CONTRACT with every account that already exists: if any of them changes,
|
||||
* existing users cannot log in and their capsules cannot be decrypted. There
|
||||
* is no server-side record to migrate, because that is the entire design.
|
||||
*
|
||||
* The existing crypto tests check self-consistency -- same input, same output
|
||||
* within one run -- which cannot catch that. A dependency upgrade that alters
|
||||
* HKDF, the scalar loop, base64url, BIP-39 seeding, or JCS ordering would keep
|
||||
* every one of those tests green while locking every user out.
|
||||
*
|
||||
* These vectors were captured from the shipping implementation on 2026-07-31.
|
||||
* They are not claims about what the values SHOULD be in the abstract; they
|
||||
* are a record of what live accounts already depend on. A change here is
|
||||
* therefore never "update the expected value" -- it means the upgrade being
|
||||
* attempted is account-breaking and needs a migration plan or rejection.
|
||||
*
|
||||
* Prompted by a Dependabot PR proposing @noble/hashes 1.8 -> 2.2 and
|
||||
* @scure/base 1.2 -> 2.2 in a single grouped change.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hkdfExtract, hkdfExpand, labelSalt } from "./hkdf.js";
|
||||
import { scalarExpand } from "./scalarExpand.js";
|
||||
import { getPublicKeyHex } from "./secp256k1.js";
|
||||
import { bytesToHex, utf8ToBytes, bytesToBase64url } from "./encoding.js";
|
||||
import { entropyToRecoveryPhrase, recoveryPhraseToSeed } from "./bip39.js";
|
||||
import { canonicalJson } from "./jcs.js";
|
||||
|
||||
const IKM = "kat-ikm-v1";
|
||||
const ROOT_LABEL = "bitlogin/password-root/v1";
|
||||
const SIGNING_LABEL = "bitlogin/password-locator-signing/v1";
|
||||
|
||||
describe("derivation chain known answers (account compatibility)", () => {
|
||||
it("HKDF-Extract is unchanged", () => {
|
||||
const prk = hkdfExtract(labelSalt(ROOT_LABEL), utf8ToBytes(IKM));
|
||||
expect(bytesToHex(prk)).toBe(
|
||||
"a6cd2bc970db284dc11d54e25428789558e6bc3e3e8d254c1f80acd6ec42b246",
|
||||
);
|
||||
});
|
||||
|
||||
it("HKDF-Expand is unchanged", () => {
|
||||
const prk = hkdfExtract(labelSalt(ROOT_LABEL), utf8ToBytes(IKM));
|
||||
expect(bytesToHex(hkdfExpand(prk, "bitlogin/kat/v1", 32))).toBe(
|
||||
"0f9d275238cdb502b5d08dbe94ec6d91b03124fd7735334b39fb7110826f40aa",
|
||||
);
|
||||
});
|
||||
|
||||
it("ScalarExpand yields the same scalar, counter, and public key", () => {
|
||||
// The public key is the one a relay has seen sign this account's capsule.
|
||||
// If it moves, the account's own history stops verifying.
|
||||
const prk = hkdfExtract(labelSalt(ROOT_LABEL), utf8ToBytes(IKM));
|
||||
const result = scalarExpand(prk, SIGNING_LABEL);
|
||||
expect(bytesToHex(result.scalar)).toBe(
|
||||
"f4838ec5489f243540ce4323a532be0d5246069d4375cb30b5a450aeb4553f8a",
|
||||
);
|
||||
expect(result.counter).toBe(0);
|
||||
expect(getPublicKeyHex(result.scalar)).toBe(
|
||||
"f047bc5a592d2a53c19503c14c2ba8fcfa4437d1d2e2c68fc03f9f9752bbe482",
|
||||
);
|
||||
});
|
||||
|
||||
it("BIP-39 phrase and seed derivation are unchanged", () => {
|
||||
// Recovery is the last way back into an account. A changed wordlist or
|
||||
// seeding function turns every printed recovery phrase into confetti.
|
||||
const phrase = entropyToRecoveryPhrase(new Uint8Array(16).fill(7));
|
||||
expect(phrase).toBe(
|
||||
"alpha deal scrub asthma idea logic bright thought alpha deal scrub autumn",
|
||||
);
|
||||
});
|
||||
|
||||
it("BIP-39 seed bytes are unchanged", async () => {
|
||||
const seed = await recoveryPhraseToSeed(
|
||||
"alpha deal scrub asthma idea logic bright thought alpha deal scrub autumn",
|
||||
);
|
||||
expect(bytesToHex(seed).slice(0, 64)).toBe(
|
||||
"8252a324fac1a2960625f829d70d720c912cdf7c683f9772c3a55d57d9f8a983",
|
||||
);
|
||||
});
|
||||
|
||||
it("base64url encoding is unchanged", () => {
|
||||
// @scure/base is in the Dependabot bump. Unpadded, URL-safe alphabet: a
|
||||
// change here alters every stored capsule payload's framing.
|
||||
expect(bytesToBase64url(new Uint8Array([0, 1, 250, 255]))).toBe("AAH6_w");
|
||||
});
|
||||
|
||||
it("JCS canonicalisation ordering is unchanged", () => {
|
||||
// Capsules are signed over canonical JSON, so a different key order is a
|
||||
// different signature over what is otherwise the same object.
|
||||
expect(canonicalJson({ b: 1, a: [2, { d: 4, c: 3 }] })).toBe('{"a":[2,{"c":3,"d":4}],"b":1}');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user