diff --git a/docs/design/spl-blob-uplink-v1.md b/docs/design/spl-blob-uplink-v1.md index 68996b8df..770e05084 100644 --- a/docs/design/spl-blob-uplink-v1.md +++ b/docs/design/spl-blob-uplink-v1.md @@ -103,9 +103,13 @@ Inner pairing JSON is compact UTF-8 with no whitespace. Binary fields are base64url unpadded strings. 1. ext->home PairHello: magic `"SBP1"` | version `0x01`. -2. home->ext signed identity: `{ "pkH_spki", "instance_id", "sig" }`. +2. home->ext signed identity: `{ "pkH_spki", "ca_spki", "instance_id", "sig" }`. `sig` is raw IEEE-P1363 `R || S`, 64 bytes. It signs raw bytes: `"spl-pair-browser-v1" || pkH_spki_DER || instance_id_16`. + `ca_spki` is the CA public-key SPKI DER (base64url); the browser holds only + the 16-byte `ca_fp_spki` pin from the 0x06 link, so the home must supply the + full CA key here. The browser checks `SHA-256(ca_spki)[:16] == ca_fp_spki`, + then verifies `sig` with it. 3. ext->home HPKE base-mode seal to pkH, info=`instance_id_16`, AAD=`b""`. Plaintext is `{ "S", "ext_pub_spki", "device_label" }`. 4. home verifies `S` through the existing single-use nonce store, registers the diff --git a/solstone/think/link/browser_pairing.py b/solstone/think/link/browser_pairing.py index 734247d1e..a3293c343 100644 --- a/solstone/think/link/browser_pairing.py +++ b/solstone/think/link/browser_pairing.py @@ -68,6 +68,7 @@ async def register_browser( _json_bytes( { "pkH_spki": _b64u(upload_key.public_spki_der), + "ca_spki": _b64u(ca.public_spki_der()), "instance_id": state.instance_id, "sig": _b64u(sig_raw), } diff --git a/solstone/think/link/ca.py b/solstone/think/link/ca.py index a9d1b9a7a..8d9b7af99 100644 --- a/solstone/think/link/ca.py +++ b/solstone/think/link/ca.py @@ -52,13 +52,21 @@ class LoadedCa: """SHA-256 of the CA cert DER — used as the CA identifier at /enroll/home.""" return _hex_sha256(self.cert.public_bytes(serialization.Encoding.DER)) - def spki_fingerprint_sha256(self) -> str: - """SHA-256 of DER SPKI; matches spl-relay enroll.ts ca_fp, not cert DER.""" - spki_der = self.cert.public_key().public_bytes( + def public_spki_der(self) -> bytes: + """DER SubjectPublicKeyInfo of the CA public key. + + These are the bytes whose SHA-256 first-16 form the ``ca_fp_spki`` pin + carried in the 0x06 pair link, so a browser client that only holds the + pin can be handed this key to verify the home's pairing signature. + """ + return self.cert.public_key().public_bytes( serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo, ) - return _hex_sha256(spki_der) + + def spki_fingerprint_sha256(self) -> str: + """SHA-256 of DER SPKI; matches spl-relay enroll.ts ca_fp, not cert DER.""" + return _hex_sha256(self.public_spki_der()) def generate_ca( diff --git a/tests/link/test_browser_pairing.py b/tests/link/test_browser_pairing.py index 4b00a99f1..97caed056 100644 --- a/tests/link/test_browser_pairing.py +++ b/tests/link/test_browser_pairing.py @@ -101,6 +101,19 @@ async def test_browser_pairing_registers_observer_and_returns_attestation( ) assert _b64u_decode(signed_identity["pkH_spki"]) == home_key.public_spki_der assert signed_identity["instance_id"] == state.instance_id + # msg2 must carry the CA public SPKI so a browser holding only the 0x06 link's + # 16-byte ca_fp_spki pin can verify the signature above. The transmitted key + # must equal the CA's SPKI DER and hash to the link pin. + ca_spki = _b64u_decode(signed_identity["ca_spki"]) + assert ca_spki == ca.public_spki_der() + assert _sha256(ca_spki)[:16] == bytes.fromhex(ca.spki_fingerprint_sha256())[:16] + # And the signature must verify using only the transmitted ca_spki (what the + # extension actually does — it never has the CA cert, only this SPKI). + serialization.load_der_public_key(ca_spki).verify( + sig_der, + PAIR_LABEL + home_key.public_spki_der + uuid_bytes(state.instance_id), + ec.ECDSA(hashes.SHA256()), + ) entry = AuthorizedClients(authorized_clients_path()).get( "sha256:" + _sha256(ext_spki).hex()