tangled
alpha
login
or
join now
julien.rbrt.fr
/
vow
forked from
hailey.at/cocoon
0
fork
atom
Vow, uncensorable PDS written in Go
0
fork
atom
overview
issues
pulls
pipelines
fix: derive on client
julien.rbrt.fr
1 day ago
6a79faff
d77acc5e
1/1
ci.yml
success
1min 3s
+70
-40
3 changed files
expand all
collapse all
unified
split
server
handle_server_supply_signing_key.go
handle_signer_connect.go
templates
account.html
+9
-14
server/handle_server_supply_signing_key.go
···
29
29
// AttestationObject is the base64url-encoded attestationObject CBOR from
30
30
// the AuthenticatorAttestationResponse.
31
31
AttestationObject string `json:"attestationObject" validate:"required"`
32
32
-
// PrfOutput is the base64url-encoded PRF output.
33
33
-
PrfOutput string `json:"prfOutput" validate:"required"`
32
32
+
// SigningPublicKey is the base64url-encoded compressed P-256 public key
33
33
+
// derived via the PRF extension.
34
34
+
SigningPublicKey string `json:"signingPublicKey" validate:"required"`
34
35
}
35
36
36
37
type SupplySigningKeyResponse struct {
···
104
105
return
105
106
}
106
107
107
107
-
prfOutput, err := base64.RawURLEncoding.DecodeString(req.PrfOutput)
108
108
+
signingKeyBytes, err := base64.RawURLEncoding.DecodeString(req.SigningPublicKey)
108
109
if err != nil {
109
109
-
logger.Error("error decoding prf output", "error", err)
110
110
-
helpers.InputError(w, new("invalid prf output encoding"))
110
110
+
logger.Error("error decoding signing public key", "error", err)
111
111
+
helpers.InputError(w, new("invalid signing public key encoding"))
111
112
return
112
113
}
113
114
114
114
-
privKey, err := deriveSigningKey(prfOutput)
115
115
-
if err != nil {
116
116
-
logger.Error("failed to derive signing key", "error", err)
117
117
-
helpers.ServerError(w, nil)
118
118
-
return
119
119
-
}
120
120
-
pubKey, err := privKey.PublicKey()
115
115
+
pubKey, err := atcrypto.ParsePublicBytesP256(signingKeyBytes)
121
116
if err != nil {
122
122
-
logger.Error("failed to get public key from private key", "error", err)
123
123
-
helpers.ServerError(w, nil)
117
117
+
logger.Error("derived signing key rejected by atcrypto", "error", err)
118
118
+
helpers.InputError(w, new("invalid derived P-256 public key"))
124
119
return
125
120
}
126
121
+13
-22
server/handle_signer_connect.go
···
1
1
package server
2
2
3
3
import (
4
4
-
"bytes"
5
4
"encoding/base64"
6
5
"encoding/json"
7
6
"fmt"
···
10
9
"strings"
11
10
"time"
12
11
12
12
+
"github.com/bluesky-social/indigo/atproto/atcrypto"
13
13
"github.com/gorilla/websocket"
14
14
"pkg.rbrt.fr/vow/internal/helpers"
15
15
"pkg.rbrt.fr/vow/models"
···
55
55
AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url
56
56
ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url
57
57
Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA
58
58
-
PrfOutput string `json:"prfOutput,omitempty"` // base64url
58
58
+
CommitSignature string `json:"commitSignature,omitempty"` // base64url 64-byte raw r||s signature
59
59
}
60
60
61
61
// handleSignerConnect upgrades the connection to a WebSocket and registers it
···
331
331
return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs
332
332
}
333
333
334
334
-
if in.PrfOutput == "" {
335
335
-
return nil, fmt.Errorf("missing prfOutput in sign_response")
334
334
+
if in.CommitSignature == "" {
335
335
+
return nil, fmt.Errorf("missing commitSignature in sign_response")
336
336
}
337
337
338
338
// The challenge passed to navigator.credentials.get() was the raw bytes
···
365
365
return nil, err
366
366
}
367
367
368
368
-
// Now derive the signing key from PRF output.
369
369
-
prfOutput, err := base64.RawURLEncoding.DecodeString(in.PrfOutput)
368
368
+
commitSig, err := base64.RawURLEncoding.DecodeString(in.CommitSignature)
370
369
if err != nil {
371
371
-
return nil, fmt.Errorf("invalid prfOutput encoding: %w", err)
370
370
+
return nil, fmt.Errorf("invalid commitSignature encoding: %w", err)
372
371
}
373
372
374
374
-
privKey, err := deriveSigningKey(prfOutput)
375
375
-
if err != nil {
376
376
-
return nil, fmt.Errorf("failed to derive signing key: %w", err)
373
373
+
if len(commitSig) != 64 {
374
374
+
return nil, fmt.Errorf("invalid commitSignature length: got %d, want 64", len(commitSig))
377
375
}
378
376
379
379
-
pubKey, err := privKey.PublicKey()
377
377
+
// Verify the commit signature against the registered signing key.
378
378
+
pubKey, err := atcrypto.ParsePublicBytesP256(signingPubKey)
380
379
if err != nil {
381
381
-
return nil, fmt.Errorf("failed to get public key from private key: %w", err)
380
380
+
return nil, fmt.Errorf("failed to parse registered signing key: %w", err)
382
381
}
383
382
384
384
-
// Verify that the derived signing key matches the registered one.
385
385
-
if !bytes.Equal(pubKey.Bytes(), signingPubKey) {
386
386
-
return nil, fmt.Errorf("derived signing key does not match registered signing key")
387
387
-
}
388
388
-
389
389
-
// Sign the payload with the derived key.
390
390
-
// HashAndSign hashes the data using SHA-256 and produces a low-S signature.
391
391
-
commitSig, err := privKey.HashAndSign(expectedChallenge)
392
392
-
if err != nil {
393
393
-
return nil, fmt.Errorf("failed to sign payload: %w", err)
383
383
+
if err := pubKey.HashAndVerifyLenient(expectedChallenge, commitSig); err != nil {
384
384
+
return nil, fmt.Errorf("commit signature verification failed: %w", err)
394
385
}
395
386
396
387
return commitSig, nil
+48
-4
server/templates/account.html
···
337
337
throw new Error("Passkey creation was cancelled.");
338
338
}
339
339
340
340
-
// 3. Extract PRF output
340
340
+
// 3. Extract PRF output and derive signing key
341
341
const extResults = credential.getClientExtensionResults();
342
342
if (!extResults.prf || !extResults.prf.results || !extResults.prf.results.first) {
343
343
throw new Error("Passkey doesn't support PRF extension. Use a modern browser (e.g. Chrome 126+, Safari 18+).");
344
344
}
345
345
const prfOutput = new Uint8Array(extResults.prf.results.first);
346
346
localStorage.setItem("vow_prf_output", bytesToBase64url(prfOutput));
347
347
+
348
348
+
const signingKey = await deriveSigningKey(prfOutput);
349
349
+
const rawPubKey = new Uint8Array(await window.crypto.subtle.exportKey("raw", signingKey.publicKey));
350
350
+
const compressedPubKey = compressRawPublicKey(rawPubKey);
347
351
348
352
// 4. Send the attestation response to the server.
349
353
btn.textContent = "Registering…";
···
361
365
credential.response.attestationObject,
362
366
),
363
367
),
364
364
-
prfOutput: bytesToBase64url(prfOutput),
368
368
+
signingPublicKey: bytesToBase64url(compressedPubKey),
365
369
}),
366
370
});
367
371
···
713
717
throw new Error("Passkey assertion was cancelled.");
714
718
}
715
719
720
720
+
// Derive signing key from stored PRF output
721
721
+
const prfOutputB64 = localStorage.getItem("vow_prf_output");
722
722
+
if (!prfOutputB64) {
723
723
+
throw new Error("Signing key not found in storage. Please re-register your passkey.");
724
724
+
}
725
725
+
const prfOutput = base64urlToBytes(prfOutputB64);
726
726
+
const signingKey = await deriveSigningKey(prfOutput);
727
727
+
728
728
+
// Sign the commit (which is the challenge bytes)
729
729
+
// Note: WebCrypto ECDSA sign does NOT normalize to low-S automatically,
730
730
+
// ATProto requires low-S.
731
731
+
const rawSig = new Uint8Array(await window.crypto.subtle.sign(
732
732
+
{
733
733
+
name: "ECDSA",
734
734
+
hash: { name: "SHA-256" }
735
735
+
},
736
736
+
signingKey.privateKey,
737
737
+
challenge
738
738
+
));
739
739
+
740
740
+
// Convert P-256 signature to low-S as required by ATProto
741
741
+
const S_BYTES = rawSig.slice(32, 64);
742
742
+
const S_INT = BigInt("0x" + Array.from(S_BYTES).map(b => b.toString(16).padStart(2, "0")).join(""));
743
743
+
const N = BigInt("0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551");
744
744
+
const HALF_N = N / 2n;
745
745
+
746
746
+
let commitSignature = rawSig;
747
747
+
if (S_INT > HALF_N) {
748
748
+
const lowS = N - S_INT;
749
749
+
const lowSHex = lowS.toString(16).padStart(64, "0");
750
750
+
const lowSBytes = new Uint8Array(32);
751
751
+
for (let i = 0; i < 32; i++) {
752
752
+
lowSBytes[i] = parseInt(lowSHex.slice(i * 2, i * 2 + 2), 16);
753
753
+
}
754
754
+
commitSignature = new Uint8Array(64);
755
755
+
commitSignature.set(rawSig.slice(0, 32), 0);
756
756
+
commitSignature.set(lowSBytes, 32);
757
757
+
}
758
758
+
716
759
wsSend(
717
760
buildSignResponse(
718
761
requestId,
719
762
assertion.response,
763
763
+
commitSignature
720
764
),
721
765
);
722
766
} catch (err) {
···
732
776
// Protocol message builders
733
777
// ---------------------------------------------------------------------------
734
778
735
735
-
function buildSignResponse(requestId, assertionResponse) {
779
779
+
function buildSignResponse(requestId, assertionResponse, commitSignature) {
736
780
return JSON.stringify({
737
781
type: "sign_response",
738
782
requestId: requestId,
···
747
791
signature: bytesToBase64url(
748
792
new Uint8Array(assertionResponse.signature),
749
793
),
750
750
-
prfOutput: localStorage.getItem("vow_prf_output"),
794
794
+
commitSignature: bytesToBase64url(commitSignature),
751
795
});
752
796
}
753
797