feat: bind saved keys to versioned wallet owners

This commit is contained in:
2026-08-22 16:54:48 +02:00
parent 0e0133f550
commit acfab83438
6 changed files with 788 additions and 100 deletions
+130
View File
@@ -0,0 +1,130 @@
// Saved linking-key records have two safe at-rest forms: ownerless legacy
// records and version-1 owner-bearing records. The briefly shipped
// unversioned owner-bearing shape remains readable only so a proven key can
// upgrade it; it never establishes ownership by itself. Any other metadata
// is rejected rather than downgraded to adoptable legacy data.
import {isJsonObject} from '../jsonParsing'
import {isWalletOwnerId} from './walletOwner'
export const STORED_SECRET_VERSION = 1 as const
type PlainStoredSecret = {
readonly enc: false
readonly value: string
readonly ownerId?: unknown
readonly version?: unknown
}
type EncryptedStoredSecret = {
readonly enc: true
readonly salt: string
readonly iv: string
readonly ciphertext: string
readonly ownerId?: unknown
readonly version?: unknown
}
export type StoredSecret = PlainStoredSecret | EncryptedStoredSecret
type ParsedStoredSecret = {
readonly secret: StoredSecret
readonly claimedOwnerId: string | null
readonly isCurrent: boolean
}
const PLAIN_KEYS = ['enc', 'value', 'ownerId', 'version'] as const
const ENCRYPTED_KEYS = ['enc', 'salt', 'iv', 'ciphertext', 'ownerId', 'version'] as const
const hasOnlyKeys = (record: Record<string, unknown>, allowed: readonly string[]): boolean =>
Object.keys(record).every((key) => allowed.includes(key))
export const parseStoredSecret = (stored: unknown): ParsedStoredSecret | null => {
if (!isJsonObject(stored)) return null
let secret: StoredSecret
if (stored.enc === false) {
if (
typeof stored.value !== 'string' ||
!/^[0-9a-f]{64}$/i.test(stored.value) ||
!hasOnlyKeys(stored, PLAIN_KEYS)
) {
return null
}
secret = {enc: false, value: stored.value}
} else if (stored.enc === true) {
if (
typeof stored.salt !== 'string' ||
!/^[0-9a-f]{32}$/i.test(stored.salt) ||
typeof stored.iv !== 'string' ||
!/^[0-9a-f]{24}$/i.test(stored.iv) ||
typeof stored.ciphertext !== 'string' ||
stored.ciphertext.length === 0 ||
stored.ciphertext.length % 2 !== 0 ||
!/^[0-9a-f]+$/i.test(stored.ciphertext) ||
!hasOnlyKeys(stored, ENCRYPTED_KEYS)
) {
return null
}
secret = {
enc: true,
salt: stored.salt,
iv: stored.iv,
ciphertext: stored.ciphertext,
}
} else {
return null
}
const hasOwner = Object.hasOwn(stored, 'ownerId')
const hasVersion = Object.hasOwn(stored, 'version')
if (!hasOwner && !hasVersion) return {secret, claimedOwnerId: null, isCurrent: false}
if (!isWalletOwnerId(stored.ownerId)) return null
if (!hasVersion) {
return {
secret: {...secret, ownerId: stored.ownerId},
claimedOwnerId: stored.ownerId,
isCurrent: false,
}
}
if (stored.version !== STORED_SECRET_VERSION) return null
return {
secret: {...secret, ownerId: stored.ownerId, version: STORED_SECRET_VERSION},
claimedOwnerId: stored.ownerId,
isCurrent: true,
}
}
export const isValidStoredSecret = (stored: unknown): stored is StoredSecret =>
parseStoredSecret(stored) !== null
export const storedSecretOwnerId = (stored: StoredSecret): string | null => {
const parsed = parseStoredSecret(stored)
return parsed?.isCurrent === true ? parsed.claimedOwnerId : null
}
export const storedSecretClaimedOwnerId = (stored: StoredSecret): string | null =>
parseStoredSecret(stored)?.claimedOwnerId ?? null
export const stampStoredSecretOwner = (stored: StoredSecret, ownerId: string): StoredSecret => {
if (stored.enc === false) {
return {enc: false, value: stored.value, ownerId, version: STORED_SECRET_VERSION}
}
return {
enc: true,
salt: stored.salt,
iv: stored.iv,
ciphertext: stored.ciphertext,
ownerId,
version: STORED_SECRET_VERSION,
}
}
export const stripStoredSecretOwner = (stored: StoredSecret): StoredSecret => {
if (stored.enc === false) return {enc: false, value: stored.value}
return {
enc: true,
salt: stored.salt,
iv: stored.iv,
ciphertext: stored.ciphertext,
}
}
+36
View File
@@ -0,0 +1,36 @@
// Wallet owner marker. The saved linking-key record (and, in later work,
// every credential-ish record: passkey slots, NWC connections, the
// trusted-mint registry) carries an ownerId binding it to exactly one
// wallet identity, so a restored or foreign wallet can never inherit
// residue from a previous one and a stale tab cannot act for a replaced
// owner.
//
// The canonical ownerId is linkingPubKeyHex(linkingKey): the lowercase
// 66-char compressed secp256k1 pubkey hex of the wallet's LUD-05 linking
// key. It is a PUBLIC value - knowing it proves nothing. That is why a
// marker may only be WRITTEN from a freshly derived or freshly proven key
// (a new save, or ensureSavedKeyOwner after a successful unlock) and never
// copied from a backup file, a credential id, or any other stored claim.
//
// Failure model: localStorage is hand-editable and backups are hostile
// input, so a marker is never trusted on presence alone. Anything that is
// not byte-exactly a valid compressed-pubkey hex - wrong length, wrong
// case, non-hex, off-curve, non-string - is rejected by its owning schema.
// Only a record with no ownership metadata at all is legacy ownerless data;
// malformed or future metadata must never be downgraded into that adoptable
// path.
import {secp256k1} from '@noble/curves/secp256k1.js'
// strict shape AND curve check: exactly what linkingPubKeyHex can produce
export const isWalletOwnerId = (ownerId: unknown): ownerId is string => {
if (typeof ownerId !== 'string' || !/^0[23][0-9a-f]{64}$/.test(ownerId)) {
return false
}
try {
secp256k1.Point.fromHex(ownerId)
return true
} catch {
return false
}
}