Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions docs/checked-key-material.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Team-owned checked key material

Each `Team` owns a private `CheckedKeyStore`. Import copies a keyset, validates
its complete shape and both public/secret keypair correspondences, computes its
commitment, and freezes the owned data. Reusing that owned record does not repeat
those checks. Only the Team's own lockbox keys are pinned for the store's lifetime;
an explicit caller-owned key argument is imported and checked on each call, is
referenced only weakly by the store, and is collectable once the caller drops it,
so explicit lookups with unrelated keysets cannot grow the store. Mutating the
original device keys cannot change the team's owned copy.

A successful lockbox opening is indexed by the complete delivery and the checked
recipient commitment. The delivery identity includes ciphertext, sender key, and
both manifests. A known contents commitment never substitutes for checking a new
delivery. Reconstructed copies of the same delivery can reuse the record across
graph replay and garbage collection. Failed openings do not create delivery records.

The current state's lockboxes determine reachability and scope/generation selection.
Retained material is not authority: a branch without a delivery or a state with
removed role access cannot select the retained key. The store retains only one
selection view, and only immutable reducer collections can reuse that view.

Material and successful delivery records live with the owning `Team`; they are
not global and are not serialized. A failed merge discards the store, including
keys opened while decrypting a valid prefix of a rejected graph. This prevents
rejected updates from accumulating retained material; the next use checks keys
again. Successful merges keep their records. Standalone selectors use a temporary store
unless given an explicit owner, and standalone `lockbox.open` returns a fresh
caller-owned copy. Existing Team key, encryption, and verification APIs and wire
formats are unchanged. Ordinary message signatures are still verified individually.

Graph-level facts are owned by the retained graph links, never by the temporary
copies `getSequence` makes: link signatures are bound to (hash, signature, resolved
public key), decrypted link bodies to (ciphertext hash, sender key, recipient key,
decryption secret) with plaintext copied on every return, and invitation/possession
proofs to (context, payload, signature, public key). Each has a bounded recent index
of `WeakRef`s (4,096 entries; 8 proof facts per owner) that only finds a fact a live
link still owns; without `WeakRef` only the owner-keyed weak maps remain. Established
commitments are indexed in an immutable radix trie extended per accepted collection.
Quiet's channel index and the separate native crypto adapter are outside this change.

## Validation

```sh
pnpm --filter @localfirst/auth... build
pnpm exec vitest run packages/auth/src packages/crdx/src packages/crypto/src packages/shared/src
```

`checkedKeyMessages.test.ts` decrypts 1,000 real role-encrypted messages without
lockbox opening, keypair validation, or key hashing after warm-up, while verifying
all 1,000 signatures and rejecting tampering. It also tests serialized graph
updates, retained removal behavior, and mutation of explicit recipient inputs.
Repeated semantic and ciphertext rejections leave graph/state unchanged and release
speculative keys; a later successful merge checks them again and retains them.
`checkedKeyStore.test.ts` covers complete delivery binding, reconstructed
collections without WeakRef, branch isolation, malformed deliveries, and ownership.

The existing Quiet `incremental-edition-bench.mjs` was also run with a separate
sender process and forced GC before each received edition at 10 and 100 users.
Both sizes required one new graph signature per edition. The first edition opened
four boxes (including initial local key import); the second opened one, with no
keypair reconstruction. This is a Linux Node 24 control, not a device benchmark.
19 changes: 13 additions & 6 deletions packages/auth/src/invitation/possession.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { verifyGraphProof } from './verifiedGraphProof.js'
import { type TeamLink } from 'team/types.js'
import { redactKeys } from '@localfirst/crdx'
import { signatures, DEVICE_POSSESSION, type Base58, type Payload } from '@localfirst/crypto'
import { assert } from '@localfirst/shared'
Expand Down Expand Up @@ -59,20 +61,25 @@ export const validatePossessionProof = ({
invitationId,
claim,
proof,
owner,
}: {
invitationId: Base58
claim: InvitationClaim
proof: Base58
owner?: TeamLink
}): ValidationResult => {
const claimValidation = validateClaim(claim)
if (!claimValidation.isValid) return claimValidation

const signatureIsValid = signatures.verify({
payload: possessionProofPayload(invitationId, claim),
signature: proof,
publicKey: claim.device.keys.signature,
context: DEVICE_POSSESSION,
})
const signatureIsValid = verifyGraphProof(
{
payload: possessionProofPayload(invitationId, claim),
signature: proof,
publicKey: claim.device.keys.signature,
context: DEVICE_POSSESSION,
},
owner
)
if (!signatureIsValid) {
return fail('The device did not prove possession of its own keys', { invitationId, claim })
}
Expand Down
110 changes: 110 additions & 0 deletions packages/auth/src/invitation/test/verifiedGraphProof.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { createKeyset, getSequence } from '@localfirst/crdx'
import {
signatures,
INVITATION_PROOF,
DEVICE_POSSESSION,
type SignedMessage,
} from '@localfirst/crypto'
import { verifyGraphProof } from 'invitation/verifiedGraphProof.js'
import { setup } from 'util/testing/index.js'
import { describe, expect, it, vi } from 'vitest'

const { alice } = setup('alice')
const owner = alice.team.graph.links[alice.team.graph.root]

describe('owned graph proof signatures', () => {
it('keeps proof facts with retained graph inputs rather than temporary sequence copies', () => {
const keys = createKeyset({ type: 'DEVICE', name: 'retained-proof' })
const input = {
payload: 'owned proof',
context: INVITATION_PROOF,
signature: signatures.sign('owned proof', keys.signature.secretKey, INVITATION_PROOF),
publicKey: keys.signature.publicKey,
}
const sequenced = getSequence(alice.team.graph).find(link => link.hash === owner.hash)!
const verify = vi.spyOn(signatures, 'verify')
vi.stubGlobal('WeakRef', undefined)
try {
expect(verifyGraphProof(input, sequenced)).toBe(true)
expect(verifyGraphProof(input, owner)).toBe(true)
expect(verify).toHaveBeenCalledTimes(1)
expect(verifyGraphProof({ ...input, payload: 'changed' }, owner)).toBe(false)
expect(verify).toHaveBeenCalledTimes(2)
} finally {
vi.unstubAllGlobals()
verify.mockRestore()
}
})
it('reuses only the complete context/payload/signature/resolved-key fact', () => {
const keys = createKeyset({ type: 'DEVICE', name: 'proof-signer' })
const other = createKeyset({ type: 'DEVICE', name: 'other-proof-signer' })
const payload = ['invitation', { identity: 'alice', bytes: Uint8Array.from([1, 2, 3]) }]
const input: SignedMessage = {
payload,
context: INVITATION_PROOF,
signature: signatures.sign(payload, keys.signature.secretKey, INVITATION_PROOF),
publicKey: keys.signature.publicKey,
}
const verify = vi.spyOn(signatures, 'verify')
try {
expect(verifyGraphProof(input, owner)).toBe(true)
for (let n = 0; n < 1000; n++) expect(verifyGraphProof(input, { ...owner })).toBe(true)
expect(verify).toHaveBeenCalledTimes(1)
expect(verifyGraphProof({ ...input, context: DEVICE_POSSESSION }, owner)).toBe(false)
expect(verifyGraphProof({ ...input, publicKey: other.signature.publicKey }, owner)).toBe(
false
)
expect(
verifyGraphProof(
{
...input,
signature: signatures.sign('different', keys.signature.secretKey, INVITATION_PROOF),
},
owner
)
).toBe(false)
;(payload[1] as { bytes: Uint8Array }).bytes[0] = 9
expect(verifyGraphProof(input, owner)).toBe(false)
expect(verifyGraphProof(input, owner)).toBe(false) // Failed checks are never cached.
expect(verify).toHaveBeenCalledTimes(6)
} finally {
verify.mockRestore()
}
})

it('remains correct without WeakRef support', () => {
const keys = createKeyset({ type: 'DEVICE', name: 'no-weakref-proof' })
const input = {
payload: 'no WeakRef',
context: INVITATION_PROOF,
signature: signatures.sign('no WeakRef', keys.signature.secretKey, INVITATION_PROOF),
publicKey: keys.signature.publicKey,
}
vi.stubGlobal('WeakRef', undefined)
try {
expect(verifyGraphProof(input, owner)).toBe(true)
expect(verifyGraphProof(input, { ...owner })).toBe(true)
expect(verifyGraphProof({ ...input, payload: 'tampered' }, owner)).toBe(false)
} finally {
vi.unstubAllGlobals()
}
})

it('keeps non-graph handshake signature checks uncached', () => {
const keys = createKeyset({ type: 'DEVICE', name: 'handshake-signer' })
const input = {
payload: 'proof',
context: INVITATION_PROOF,
signature: signatures.sign('proof', keys.signature.secretKey, INVITATION_PROOF),
publicKey: keys.signature.publicKey,
}
const verify = vi.spyOn(signatures, 'verify')
try {
expect(verifyGraphProof(input)).toBe(true)
expect(verifyGraphProof(input)).toBe(true)
expect(verify).toHaveBeenCalledTimes(2)
} finally {
verify.mockRestore()
}
})
})
22 changes: 14 additions & 8 deletions packages/auth/src/invitation/validate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { signatures, INVITATION_PROOF, type Base58 } from '@localfirst/crypto'
import { verifyGraphProof } from './verifiedGraphProof.js'
import { type TeamLink } from 'team/types.js'
import { INVITATION_PROOF, type Base58 } from '@localfirst/crypto'
import { deviceIdentityIsValid } from 'device/index.js'
import {
type Invitation,
Expand Down Expand Up @@ -39,7 +41,8 @@ export const validate = (
proof: ProofOfInvitation,
invitation: Invitation,
claim: InvitationClaim,
expectedIdentityNonce?: Base58
expectedIdentityNonce?: Base58,
owner?: TeamLink
): ValidationResult => {
if (!hasExactKeys(proof, ['id', 'identityNonce', 'inviteeNonce', 'signature'])) {
return fail('Invitation proof has extra or missing fields')
Expand All @@ -58,12 +61,15 @@ export const validate = (
if (!claimValidation.isValid) return claimValidation

// Check signature on proof against public key from invitation
const signatureIsValid = signatures.verify({
payload: invitationProofPayload(proof, claim),
signature: proof.signature,
publicKey: invitation.publicKey,
context: INVITATION_PROOF,
})
const signatureIsValid = verifyGraphProof(
{
payload: invitationProofPayload(proof, claim),
signature: proof.signature,
publicKey: invitation.publicKey,
context: INVITATION_PROOF,
},
owner
)
if (!signatureIsValid) {
return fail('Signature provided is not valid', { proof, invitation })
}
Expand Down
44 changes: 44 additions & 0 deletions packages/auth/src/invitation/verifiedGraphProof.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { hash, signatures, type SignedMessage, type Payload } from '@localfirst/crypto'
import { type TeamLink } from 'team/types.js'
import { pack, unpack } from 'msgpackr'
import { getLinkValidationOwner } from '@localfirst/crdx'

/** Cache only the signature fact, never invitation validity, identity, expiry or authorization.
* The owning graph link keeps facts alive. Replayed links can find a still-owned identical proof
* through a bounded weak index. Handshake callers without an owned graph link do not use it.
*/
export const verifyGraphProof = (input: SignedMessage, inputOwner?: TeamLink): boolean => {
if (inputOwner === undefined) return signatures.verify(input)
const owner = getLinkValidationOwner(inputOwner)
// Snapshot the exact complete input so caller-controlled nested values cannot alias the fact.
const signed = unpack(pack(input)) as SignedMessage
const identity = hash('localfirst-auth/verified-graph-proof', [
signed.context,
signed.payload,
signed.signature,
signed.publicKey,
] as Payload)
const previous = facts.get(owner)?.get(identity) ?? recentFacts.get(identity)?.deref()
if (previous !== undefined) {
remember(owner, previous)
return true
}
const valid = signatures.verify(signed)
if (valid) remember(owner, { identity })
return valid
}

type ProofFact = { identity: string }
const facts = new WeakMap<Record<string, unknown>, Map<string, ProofFact>>()
const recentFacts = new Map<string, WeakRef<ProofFact>>()
const remember = (owner: Record<string, unknown>, fact: ProofFact) => {
const { identity } = fact
const owned = facts.get(owner) ?? new Map<string, ProofFact>()
owned.set(identity, fact)
if (owned.size > 8) owned.delete(owned.keys().next().value)
facts.set(owner, owned)
if (typeof WeakRef !== 'function') return
recentFacts.delete(identity)
recentFacts.set(identity, new WeakRef(fact))
if (recentFacts.size > 4096) recentFacts.delete(recentFacts.keys().next().value)
}
Loading
Loading