Proof of interaction. Two keys co-sign a recent block; Vestige records, on-chain, that they met and when. Passkeys and device keys, one address on Ethereum, Base and Monad.
翻訳待ち
ソーシャルJavaScript · HTMLGitHub @Enigma-Technologies-Solutionsオンライン所有者未確認
Have you tried it? One tap is enough. No sign-in needed.
No updates detected yet. GitHub releases will appear here.
On-chain proof of interaction: two P-256 keys (passkeys or device keys) co-sign a recent block. One address on Ethereum, Base and Monad.
翻訳待ち
Loading the full README.
Read the full README ↗On-chain verification that two P-256 keys co-signed a recent block hash. Successor to
onchecked (secp256k1 wallets, PoE.sol) and
troye (WebCrypto checkpoint + passkey, off-chain checks).
M = abi.encode(STATEMENT_TAG, chainId, vestige, n, blockhash(n), maxAge)
digest = sha256(M)
signer signs digest (raw P-256, or a WebAuthn assertion whose challenge is digest)
cosigner signs keccak256(abi.encode(COSIGN_TAG, digest, signerKey)) (WebAuthn assertion)
Vestige.cosign(...) checks the window, the hash of block n, both signatures, then records + emits Cosigned
A Cosigned event proves the cosigner acted after block n existed (block hashes are unpredictable),
before the inclusion block, within the window the signer declared, and over this signer's statement. Proximity comes from the transport
(the cosigner scans the signer's QR, NFC tag, BLE advert…); it is not proven cryptographically.
| Mode | Signer (A) | SignatureKind | Cosigner (B) |
|---|---|---|---|
| Beacon | Unattended device key (kiosk, door reader, IoT) re-signing every block | P256 | Passkey |
| Handshake | A person's passkey, one tap; B then has the declared window | WebAuthn | Another passkey |
contracts/Vestige.solcosign(Statement statement, PublicKey cosigner, WebAuthn.WebAuthnAuth auth) where
Statement { PublicKey signer; SignatureKind kind; uint256 blockNumber; bytes32 blockHash; uint256 maxAge; bytes signature; }.
The contract has no constructor arguments, no immutables and no admin, so one build deploys to the same CREATE2 address
on every chain. User verification is always required (REQUIRE_USER_VERIFICATION = true).
signature is r ‖ s (64 bytes) for P256, or the flat ABI encoding of a WebAuthnAuth
(abi.encode(r, s, challengeIndex, typeIndex, authenticatorData, clientDataJSON)) for WebAuthn.
| Check | Failure |
|---|---|
signer's window 1 <= maxAge <= 8191 | InvalidMaxAge |
n < block.number and block.number - n <= maxAge | StaleBlock |
hash of block n is available: blockhash within 256 blocks, EIP-2935 history contract beyond | BlockHashUnavailable |
that hash == blockHash | BlockHashMismatch |
| signer key ≠ cosigner key | SelfCosign |
| signature decodes for its declared kind | MalformedSignature |
signer's P-256 signature over digest, or WebAuthn assertion with challenge digest | InvalidSignerSignature |
| cosigner's WebAuthn assertion over the cosign challenge (type, challenge, UP, UV, BE/BS) | InvalidCosignerSignature |
cosignId = keccak256(digest, signerKey, cosignerKey) not yet recorded | AlreadyRecorded |
verify() is a view with the same checks and no state change; cosign() records. Anyone may submit. Both
return (cosignId, age), where age = block.number - n.Cosigned(cosignId, signerKey, cosignerKey, signerKind, blockNumber, blockHash, maxAge, age): the kind tells a
reader which mode produced it.maxAge is inside M, and the cosigner's challenge commits to digest.
Neither the relayer nor the cosigner can widen it: a changed maxAge fails InvalidSignerSignature. A door
beacon might declare 10 blocks and a conference handshake an hour; declaring 8191 means "as long as the chain
can check". Readers, or contracts calling verify(), may apply a tighter limit using age.digest) and the cosigner's (keccak256(COSIGN_TAG, …)) always differ, so a
passkey's assertion as signer can never be replayed as a cosigner assertion or vice versa.P256 and WebAuthn: native precompile (EIP-7951 on L1, RIP-7212 on L2s) when present,
pure-Solidity fallback otherwise.M binds chain id and contract address, so a statement is not valid on another deployment.blockhash limit) are read from the EIP-2935 history contract
(0x0000F90827F1C53a10cb7A02335B175320002935). On a chain without it they fail BlockHashUnavailable.
8191 blocks is about 27 h on Ethereum, 4.5 h on a 2 s L2 and 41 min on Monad.s; submit s' = n - s when s > n/2.
The cosigner binds the statement digest and signer key, not the signer's signature bytes, so this
normalization never invalidates the co-signature.crypto.subtle.sign({name: "ECDSA", hash: "SHA-256"}, key, M) where M is the
statementMessage(n, hash, maxAge) bytes (computable client-side; see test/Vestige.test.js).navigator.credentials.get({publicKey: {challenge: <32 bytes>, ...}}); submit
authenticatorData, clientDataJSON, and the indices of "challenge":" and "type":" in it.eth_call. eth_call runs at the head block, so verify() rejects an anchor equal to
the head. Anchor at least one block behind head (also wiser for reorgs).A chain qualifies only if all of these hold. They were probed live; the script is not part of this repo.
0x100 (EIP-7951 / RIP-7212). The Solidity fallback works but costs about 6× the gas.blockhash: the EVM returns the real hash of block n. Arbitrum fails this: it returns pseudo-random
hashes, and block.number there tracks L1.prevrandao) or blocks are full of other
parties' transactions. On-demand chains whose blocks hold about one transaction are weak here.This is the beginning of the README. Read the rest at the original. Read the full README ↗