test for xftp web handshake

This commit is contained in:
Evgeny Poberezkin
2026-02-02 17:41:25 +00:00
parent 947edc2886
commit 64089834f3
12 changed files with 821 additions and 122 deletions
+246
View File
@@ -0,0 +1,246 @@
# Web Handshake — Challenge-Response Identity Proof
RFC §6.3: Server proves XFTP identity to web clients independently of TLS CA infrastructure.
## 1. Protocol
**Standard handshake** (unchanged):
```
Client → empty POST → Server
Server → padded {vRange, sessionId, authPubKey, Nothing} → Client
Client → padded {version, keyHash, Nothing} → Server
Server → empty → Client
```
**Web handshake** (SNI connection, non-empty hello):
```
Client → padded {32 random bytes} → Server
Server → padded {vRange, sessionId, authPubKey, Just sigBytes} → Client
sigBytes = signatureBytes(sign(identityLeafKey, challenge <> sessionId))
Client validates:
1. chainIdCaCerts(authPubKey.certChain) → CCValid {leafCert, idCert}
2. SHA-256(idCert) == keyHash (server identity)
3. verify(leafCert.pubKey, sigBytes, challenge <> sessionId) (challenge-response)
4. verify(leafCert.pubKey, signedPubKey.signature, signedPubKey.objectDer) (DH key auth)
Client → padded {version, keyHash, Just challenge} → Server
Server verifies: echoed challenge == stored challenge from step 1
Server → empty → Client
```
**Detection**: `sniUsed` per-connection flag. Non-empty hello allowed only when `sniUsed`. Empty hello with SNI → standard handshake.
**Why both steps 3 and 4**: Native clients verify `signedPubKey` using the TLS peer certificate (`serverKey` from `getServerVerifyKey`), which is the XFTP identity cert in non-SNI connections — TLS provides this binding. Web clients cannot access TLS peer certificate data (browser API limitation; TLS presents the web CA cert but provides no API to extract it). So web clients must verify at the application layer using `authPubKey.certChain`, which always contains the XFTP identity chain regardless of which cert TLS used. Step 3 proves the server holds its identity key *right now* (freshness via random challenge). Step 4 proves the DH session key was signed by the identity key holder (prevents MITM key substitution). Together they give web clients some assurance native clients get from TLS, except channel binding for commands.
## 2. Type Changes — `src/Simplex/FileTransfer/Transport.hs`
### `XFTPServerHandshake` (line 114)
Add field: `webIdentityProof :: Maybe ByteString` — raw Ed448 signature bytes (114 bytes), or `Nothing` for standard handshake. No record needed — the cert chain is already in `authPubKey.certChain`.
### `Encoding XFTPServerHandshake` (line 136)
- `smpEncode`: append `smpEncode webIdentityProof`
- `smpP`: `Tail compat`, if non-empty `eitherToMaybe $ smpDecode compat`
Backward compat: old clients ignore via `Tail _compat`; new client + old server → empty compat → `Nothing`.
### `XFTPClientHandshake` (line 121)
Add field: `webChallenge :: Maybe ByteString`
### `Encoding XFTPClientHandshake` (line 128)
Same `Tail compat` pattern as server handshake.
### Export list
Both types use `(..)` export — new fields auto-exported.
## 3. Server Changes — `src/Simplex/FileTransfer/Server.hs`
### `XFTPTransportRequest` (line 88)
Add field: `sniUsed :: SNICredentialUsed` (`Bool` from `Transport.Server`). Add import.
### `Handshake` (line 117)
`HandshakeSent C.PrivateKeyX25519``HandshakeSent C.PrivateKeyX25519 (Maybe ByteString)` — stores 32-byte web challenge or `Nothing`.
### `runServer` handler (line 145161)
- Pass `sniUsed` into request construction (line 154)
- SNI-first routing: when `sniUsed`, always route to `xftpServerHandshakeV1` (web ALPN `h2` would otherwise fall to `_` catch-all)
### `xftpServerHandshakeV1` (line 162)
- Destructure `sniUsed` from request
- Match `HandshakeSent pk challenge_``processClientHandshake pk challenge_`
### `processHello` (line 171)
- Branch `(sniUsed, B.null bodyHead)`:
- `(_, True)` → standard: `challenge_ = Nothing`
- `(True, False)` → web: unpad, verify 32 bytes, `challenge_ = Just`
- `(False, False)``throwE HANDSHAKE`
- Store: `HandshakeSent pk challenge_`
- Compute: `webIdentityProof = C.signatureBytes . C.sign serverSignKey . (<> sessionId) <$> challenge_`
- Construct `XFTPServerHandshake` with `webIdentityProof`
### `processClientHandshake` (line 183)
- Accept `challenge_` parameter
- Decode `webChallenge` from `XFTPClientHandshake`
- Add: `unless (challenge_ == webChallenge) $ throwE HANDSHAKE`
(standard: both `Nothing` → passes)
## 4. Native Client — `src/Simplex/FileTransfer/Client.hs`
### `xftpClientHandshakeV1` (line 142)
Add `webChallenge = Nothing` in `sendClientHandshake` call.
No other changes — parser handles new fields via `Tail`, native client ignores `webIdentityProof`.
## 5. TypeScript Changes (DONE except Ed448)
Sections 5.1 and 5.2 are implemented. Section 5.3 needs Ed448 support.
## 10. Ed448 Support via `@noble/curves`
**Problem**: Production servers use Ed448 certificates (default). `identity.ts` only supports Ed25519 via libsodium. libsodium has no Ed448 support and never will.
**Solution**: Add `@noble/curves` dependency for Ed448 verification only. All other crypto stays with libsodium.
### 10.1 `xftp-web/package.json` — Add dependency
```json
"dependencies": {
"libsodium-wrappers-sumo": "^0.7.13",
"@noble/curves": "^1.9.7"
}
```
Use v1.x (supports both CJS and ESM). v2.x is ESM-only with `.js` extension requirement.
### 10.2 `xftp-web/src/crypto/keys.ts` — Ed448 DER constants and decode
Add Ed448 SPKI DER prefix (12 bytes, same prefix length as Ed25519):
```
30 43 30 05 06 03 2b 65 71 03 3a 00
```
| Property | Ed25519 | Ed448 |
|----------|---------|-------|
| OID | `2b 65 70` | `2b 65 71` |
| SPKI prefix | `30 2a ...` | `30 43 ...` |
| Raw key size | 32 bytes | 57 bytes |
| SPKI total | 44 bytes | 69 bytes |
| Signature size | 64 bytes | 114 bytes |
New functions:
- `decodePubKeyEd448(der: Uint8Array): Uint8Array` — 69 bytes → 57 bytes raw
- `encodePubKeyEd448(raw: Uint8Array): Uint8Array` — 57 bytes → 69 bytes DER
- `verifyEd448(publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array): boolean` — uses `ed448.verify(sig, msg, publicKey)` from `@noble/curves/ed448`
Note: `@noble/curves` parameter order is `(signature, message, publicKey)`, not `(publicKey, signature, message)`.
### 10.3 `xftp-web/src/crypto/identity.ts` — Algorithm-agnostic verification
Replace `extractCertEd25519Key` + hardcoded Ed25519 `verify` with algorithm detection:
1. `extractCertPublicKeyInfo(certDer)` → SPKI DER (already exists, works for any algorithm)
2. Detect algorithm from SPKI: byte at offset 8 is `0x70` (Ed25519) or `0x71` (Ed448)
3. Extract raw key with appropriate decoder
4. Verify signatures with appropriate function
```typescript
type CertKeyAlgorithm = 'ed25519' | 'ed448'
function detectKeyAlgorithm(spki: Uint8Array): CertKeyAlgorithm {
if (spki.length === 44 && spki[8] === 0x70) return 'ed25519'
if (spki.length === 69 && spki[8] === 0x71) return 'ed448'
throw new Error("unsupported certificate key algorithm")
}
```
`verifyIdentityProof` changes:
- Extract SPKI from leaf cert
- Detect algorithm → choose `decodePubKeyEd25519`/`decodePubKeyEd448` and `verify`/`verifyEd448`
- Both challenge signature and DH key signature use the same leaf key + algorithm
Remove `extractCertEd25519Key` (replaced by generic path). Keep `extractCertPublicKeyInfo` (already generic).
### 10.4 `xftp-web/src/protocol/handshake.ts` — Comment update
`SignedKey.signature` comment: "raw Ed25519 signature bytes (64 bytes)" → "raw signature bytes (Ed25519: 64, Ed448: 114)"
### 10.5 Tests — `tests/XFTPWebTests.hs`
**Integration test**: Switch from `withXFTPServerEd25519SNI` (Ed25519 fixtures) to `withXFTPServerSNI` (default Ed448 fixtures). Update fingerprint source from `tests/fixtures/ed25519/ca.crt` to `tests/fixtures/ca.crt`.
Optionally add a second integration test with Ed25519 to cover both paths, or rely on existing unit tests for Ed25519 coverage.
### 10.6 Implementation order
1. `npm install @noble/curves` in `xftp-web/`
2. `keys.ts` — Ed448 constants, decode, encode, verifyEd448
3. `identity.ts` — algorithm detection, generic verification
4. `handshake.ts` — comment fix
5. `XFTPWebTests.hs` — switch integration test to Ed448
6. Build TS + run all tests
## 6. Haskell Integration Test — `tests/XFTPServerTests.hs`
Add `testWebHandshake` to "XFTP SNI and CORS" describe block.
1. `withXFTPServerSNI` — server with web credentials
2. Connect with SNI + `h2` ALPN
3. Send padded 32-byte challenge
4. Decode `XFTPServerHandshake`, assert `webIdentityProof` is `Just`
5. `chainIdCaCerts` on `authPubKey.certChain``CCValid {leafCert, idCert}`
6. Verify `SHA-256(idCert) == keyHash`
7. Extract `leafCert` public key, verify challenge signature
8. Verify `signedPubKey` signature using `leafCert` key (DH key auth)
9. Send `XFTPClientHandshake` with `webChallenge = Just challenge`
10. Assert empty response
Imports: `XFTPServerHandshake (..)`, `XFTPClientHandshake (..)`, `ChainCertificates (..)`, `chainIdCaCerts`.
## 7. TS Tests — `tests/XFTPWebTests.hs`
### Unit tests
- **`decodeServerHandshake` with proof**: Haskell-encode with `Just sigBytes`, TS-decode, verify bytes match.
- **`encodeClientHandshake` with challenge**: TS-encode, compare with Haskell-encoded.
- **`chainIdCaCerts`**: 2/3/4-cert chains return correct positions.
- **`caFingerprint` (fixed)**: matches `sha256(idCert)` for 2 and 3-cert chains.
### Integration test
Node.js inline script against `withXFTPServerSNI`:
1. Connect with SNI via `http2.connect`
2. Send padded challenge, decode `XFTPServerHandshake` with TS
3. `verifyIdentityProof` — full chain validation + challenge sig + DH key sig
4. Send client handshake with echoed challenge
5. Assert empty response
## 8. Implementation Order
1. `Transport.hs``Maybe` fields + encoding instances
2. `Server.hs``sniUsed`, challenge in `Handshake`, `processHello`, `processClientHandshake`, SNI routing
3. `Client.hs``webChallenge = Nothing`
4. Build: `cabal build --ghc-options -O0`
5. Run existing SNI/CORS tests
6. `XFTPServerTests.hs``testWebHandshake`
7. `handshake.ts` — types, decoding, `chainIdCaCerts`, fix `caFingerprint`
8. `crypto/identity.ts` — Node.js verification functions
9. `XFTPWebTests.hs` — unit + integration tests
10. Build TS + run all tests
## 9. Verification
```bash
cd xftp-web && npm install && npm run build && cd ..
cabal test --ghc-options=-O0 --test-option='--match=/XFTP/XFTP server/XFTP SNI and CORS/' --test-show-details=streaming
cabal test --ghc-options=-O0 --test-option='--match=/XFTP Web Client/' --test-show-details=streaming
```
+25 -2
View File
@@ -15,7 +15,7 @@ import Simplex.FileTransfer.Client
import Simplex.FileTransfer.Description
import Simplex.FileTransfer.Server (runXFTPServerBlocking)
import Simplex.FileTransfer.Server.Env (XFTPServerConfig (..), defaultFileExpiration, defaultInactiveClientExpiration)
import Simplex.FileTransfer.Transport (supportedFileServerVRange, alpnSupportedXFTPhandshakes)
import Simplex.FileTransfer.Transport (alpnSupportedXFTPhandshakes, supportedFileServerVRange)
import Simplex.Messaging.Protocol (XFTPServer)
import Simplex.Messaging.Transport.HTTP2 (httpALPN)
import Simplex.Messaging.Transport.Server
@@ -163,8 +163,31 @@ testXFTPServerConfigSNI =
},
transportConfig =
(mkTransportServerConfig True (Just $ alpnSupportedXFTPhandshakes <> httpALPN) False)
{addCORSHeaders = True}
{ addCORSHeaders = True
}
}
withXFTPServerSNI :: HasCallStack => (HasCallStack => ThreadId -> IO a) -> IO a
withXFTPServerSNI = withXFTPServerCfg testXFTPServerConfigSNI
testXFTPServerConfigEd25519SNI :: XFTPServerConfig
testXFTPServerConfigEd25519SNI =
testXFTPServerConfig
{ xftpCredentials =
ServerCredentials
{ caCertificateFile = Just "tests/fixtures/ed25519/ca.crt",
privateKeyFile = "tests/fixtures/ed25519/server.key",
certificateFile = "tests/fixtures/ed25519/server.crt"
},
httpCredentials =
Just
ServerCredentials
{ caCertificateFile = Nothing,
privateKeyFile = "tests/fixtures/web.key",
certificateFile = "tests/fixtures/web.crt"
},
transportConfig =
(mkTransportServerConfig True (Just $ alpnSupportedXFTPhandshakes <> httpALPN) False)
{ addCORSHeaders = True
}
}
+401 -108
View File
@@ -21,6 +21,7 @@ import Data.Int (Int64)
import Data.List (intercalate)
import qualified Data.List.NonEmpty as NE
import Data.Word (Word16, Word32)
import Data.X509.Validation (Fingerprint (..))
import Simplex.FileTransfer.Client (prepareChunkSizes)
import Simplex.FileTransfer.Description (FileSize (..))
import Simplex.FileTransfer.Transport (XFTPClientHello (..))
@@ -28,12 +29,15 @@ import Simplex.FileTransfer.Types (FileHeader (..))
import qualified Simplex.Messaging.Crypto as C
import qualified Simplex.Messaging.Crypto.Lazy as LC
import Simplex.Messaging.Encoding
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Encoding.String (strDecode, strEncode)
import Simplex.Messaging.Transport.Server (loadFileFingerprint)
import System.Directory (doesDirectoryExist)
import System.Exit (ExitCode (..))
import System.Process (CreateProcess (..), StdStream (..), createProcess, proc, waitForProcess)
import Test.Hspec hiding (fit, it)
import Util
import Simplex.FileTransfer.Server.Env (XFTPServerConfig)
import XFTPClient (testXFTPServerConfigEd25519SNI, testXFTPServerConfigSNI, withXFTPServerCfg, xftpTestPort)
xftpWebDir :: FilePath
xftpWebDir = "xftp-web"
@@ -135,6 +139,9 @@ impDl =
<> "import * as Tx from './dist/protocol/transmission.js';"
<> "await sodium.ready;"
impAddr :: String
impAddr = "import * as Addr from './dist/protocol/address.js';"
-- | Wrap expression in process.stdout.write(Buffer.from(...)).
jsOut :: String -> String
jsOut expr = "process.stdout.write(Buffer.from(" <> expr <> "));"
@@ -158,6 +165,8 @@ xftpWebTests = do
tsChunkTests
tsClientTests
tsDownloadTests
tsAddressTests
tsIntegrationTests
else
it "skipped (run 'cd xftp-web && npm install && npm run build' first)" $
pendingWith "TS project not compiled"
@@ -1445,7 +1454,9 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const d = new E.Decoder(" <> jsUint8 vrBytes <> ");"
<> "const d = new E.Decoder("
<> jsUint8 vrBytes
<> ");"
<> "const vr = Hs.decodeVersionRange(d);"
<> jsOut "E.concatBytes(E.encodeWord16(vr.minVersion), E.encodeWord16(vr.maxVersion))"
tsResult `shouldBe` vrBytes
@@ -1508,7 +1519,8 @@ tsHandshakeTests = describe "protocol/handshake" $ do
-- smpEncode (versionRange, sessionId, certChainPubKey)
-- where certChainPubKey = (NonEmpty Large certChain, Large signedKey)
body =
smpEncode (1 :: Word16) <> smpEncode (3 :: Word16)
smpEncode (1 :: Word16)
<> smpEncode (3 :: Word16)
<> smpEncode sessId
<> smpEncode (NE.fromList [Large cert1, Large cert2])
<> smpEncode (Large signedKeyBytes)
@@ -1516,7 +1528,9 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const hs = Hs.decodeServerHandshake(" <> jsUint8 serverBlock <> ");"
<> "const hs = Hs.decodeServerHandshake("
<> jsUint8 serverBlock
<> ");"
<> jsOut
( "E.concatBytes("
<> "E.encodeWord16(hs.xftpVersionRange.minVersion),"
@@ -1527,11 +1541,12 @@ tsHandshakeTests = describe "protocol/handshake" $ do
)
-- Expected: vmin(2) + vmax(2) + sessId(32) + cert1(100) + cert2(32) + signedKey(120) = 288 bytes
tsResult
`shouldBe` ( smpEncode (1 :: Word16) <> smpEncode (3 :: Word16)
<> sessId
<> cert1
<> cert2
<> signedKeyBytes
`shouldBe` ( smpEncode (1 :: Word16)
<> smpEncode (3 :: Word16)
<> sessId
<> cert1
<> cert2
<> signedKeyBytes
)
it "decodeServerHandshake with webIdentityProof" $ do
@@ -1541,7 +1556,8 @@ tsHandshakeTests = describe "protocol/handshake" $ do
signedKeyBytes = B.pack [1 .. 120]
sigBytes = B.pack [1 .. 64]
body =
smpEncode (1 :: Word16) <> smpEncode (3 :: Word16)
smpEncode (1 :: Word16)
<> smpEncode (3 :: Word16)
<> smpEncode sessId
<> smpEncode (NE.fromList [Large cert1, Large cert2])
<> smpEncode (Large signedKeyBytes)
@@ -1550,7 +1566,9 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const hs = Hs.decodeServerHandshake(" <> jsUint8 serverBlock <> ");"
<> "const hs = Hs.decodeServerHandshake("
<> jsUint8 serverBlock
<> ");"
<> jsOut "hs.webIdentityProof || new Uint8Array(0)"
tsResult `shouldBe` sigBytes
@@ -1560,7 +1578,8 @@ tsHandshakeTests = describe "protocol/handshake" $ do
cert2 = B.pack [201 .. 232]
signedKeyBytes = B.pack [1 .. 120]
body =
smpEncode (1 :: Word16) <> smpEncode (3 :: Word16)
smpEncode (1 :: Word16)
<> smpEncode (3 :: Word16)
<> smpEncode sessId
<> smpEncode (NE.fromList [Large cert1, Large cert2])
<> smpEncode (Large signedKeyBytes)
@@ -1569,7 +1588,9 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const hs = Hs.decodeServerHandshake(" <> jsUint8 serverBlock <> ");"
<> "const hs = Hs.decodeServerHandshake("
<> jsUint8 serverBlock
<> ");"
<> jsOut "new Uint8Array([hs.webIdentityProof === null ? 1 : 0])"
tsResult `shouldBe` B.pack [1]
@@ -1581,7 +1602,11 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const chain = [" <> jsUint8 cert1 <> "," <> jsUint8 cert2 <> "];"
<> "const chain = ["
<> jsUint8 cert1
<> ","
<> jsUint8 cert2
<> "];"
<> jsOut "Hs.caFingerprint(chain)"
tsResult `shouldBe` expected
@@ -1593,7 +1618,13 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const chain = [" <> jsUint8 cert1 <> "," <> jsUint8 cert2 <> "," <> jsUint8 cert3 <> "];"
<> "const chain = ["
<> jsUint8 cert1
<> ","
<> jsUint8 cert2
<> ","
<> jsUint8 cert3
<> "];"
<> jsOut "Hs.caFingerprint(chain)"
tsResult `shouldBe` expected
@@ -1603,7 +1634,11 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const cc = Hs.chainIdCaCerts([" <> jsUint8 cert1 <> "," <> jsUint8 cert2 <> "]);"
<> "const cc = Hs.chainIdCaCerts(["
<> jsUint8 cert1
<> ","
<> jsUint8 cert2
<> "]);"
<> "if (cc.type !== 'valid') throw new Error('expected valid');"
<> jsOut "E.concatBytes(cc.leafCert, cc.idCert, cc.caCert)"
tsResult `shouldBe` (cert1 <> cert2 <> cert2)
@@ -1615,7 +1650,13 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const cc = Hs.chainIdCaCerts([" <> jsUint8 cert1 <> "," <> jsUint8 cert2 <> "," <> jsUint8 cert3 <> "]);"
<> "const cc = Hs.chainIdCaCerts(["
<> jsUint8 cert1
<> ","
<> jsUint8 cert2
<> ","
<> jsUint8 cert3
<> "]);"
<> "if (cc.type !== 'valid') throw new Error('expected valid');"
<> jsOut "E.concatBytes(cc.leafCert, cc.idCert, cc.caCert)"
tsResult `shouldBe` (cert1 <> cert2 <> cert3)
@@ -1629,7 +1670,13 @@ tsHandshakeTests = describe "protocol/handshake" $ do
callNode $
impHs
<> "const cc = Hs.chainIdCaCerts(["
<> jsUint8 cert1 <> "," <> jsUint8 cert2 <> "," <> jsUint8 cert3 <> "," <> jsUint8 cert4
<> jsUint8 cert1
<> ","
<> jsUint8 cert2
<> ","
<> jsUint8 cert3
<> ","
<> jsUint8 cert4
<> "]);"
<> "if (cc.type !== 'valid') throw new Error('expected valid');"
<> jsOut "E.concatBytes(cc.leafCert, cc.idCert, cc.caCert)"
@@ -1663,7 +1710,9 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const sk = Hs.extractSignedKey(" <> jsUint8 signedExactDer <> ");"
<> "const sk = Hs.extractSignedKey("
<> jsUint8 signedExactDer
<> ");"
<> jsOut "E.concatBytes(sk.dhKey, sk.signature)"
-- dhKey (32) + signature (64) = 96 bytes
tsResult `shouldBe` (dhPkRaw <> sigRaw)
@@ -1688,8 +1737,12 @@ tsHandshakeTests = describe "protocol/handshake" $ do
tsResult <-
callNode $
impHs
<> "const sk = Hs.extractSignedKey(" <> jsUint8 signedExactDer <> ");"
<> "const ok = K.verify(" <> jsUint8 signPkRaw <> ", sk.signature, sk.objectDer);"
<> "const sk = Hs.extractSignedKey("
<> jsUint8 signedExactDer
<> ");"
<> "const ok = K.verify("
<> jsUint8 signPkRaw
<> ", sk.signature, sk.objectDer);"
<> jsOut "new Uint8Array([ok ? 1 : 0])"
tsResult `shouldBe` B.pack [1]
@@ -1733,13 +1786,13 @@ tsIdentityTests = describe "crypto/identity" $ do
<> jsOut ("Id.extractCertPublicKeyInfo(" <> jsUint8 certDer <> ")")
tsResult `shouldBe` expectedSpki
it "extractCertEd25519Key returns raw 32-byte key" $ do
it "extractCertPublicKeyInfo + decodePubKey returns raw 32-byte key" $ do
let pubKey = B.pack [1 .. 32]
certDer = mkFakeCertDer pubKey
tsResult <-
callNode $
impId
<> jsOut ("Id.extractCertEd25519Key(" <> jsUint8 certDer <> ")")
<> jsOut ("K.decodePubKeyEd25519(Id.extractCertPublicKeyInfo(" <> jsUint8 certDer <> "))")
tsResult `shouldBe` pubKey
describe "verifyIdentityProof" $ do
@@ -1772,12 +1825,25 @@ tsIdentityTests = describe "crypto/identity" $ do
callNode $
impId
<> "const ok = Id.verifyIdentityProof({"
<> "certChainDer: [" <> jsUint8 leafCertDer <> "," <> jsUint8 idCertDer <> "],"
<> "signedKeyDer: " <> jsUint8 signedKeyDer <> ","
<> "sigBytes: " <> jsUint8 challengeSigRaw <> ","
<> "challenge: " <> jsUint8 challenge <> ","
<> "sessionId: " <> jsUint8 sessionId <> ","
<> "keyHash: " <> jsUint8 keyHash
<> "certChainDer: ["
<> jsUint8 leafCertDer
<> ","
<> jsUint8 idCertDer
<> "],"
<> "signedKeyDer: "
<> jsUint8 signedKeyDer
<> ","
<> "sigBytes: "
<> jsUint8 challengeSigRaw
<> ","
<> "challenge: "
<> jsUint8 challenge
<> ","
<> "sessionId: "
<> jsUint8 sessionId
<> ","
<> "keyHash: "
<> jsUint8 keyHash
<> "});"
<> jsOut "new Uint8Array([ok ? 1 : 0])"
tsResult `shouldBe` B.pack [1]
@@ -1810,12 +1876,25 @@ tsIdentityTests = describe "crypto/identity" $ do
callNode $
impId
<> "const ok = Id.verifyIdentityProof({"
<> "certChainDer: [" <> jsUint8 leafCertDer <> "," <> jsUint8 idCertDer <> "],"
<> "signedKeyDer: " <> jsUint8 signedKeyDer <> ","
<> "sigBytes: " <> jsUint8 challengeSigRaw <> ","
<> "challenge: " <> jsUint8 challenge <> ","
<> "sessionId: " <> jsUint8 sessionId <> ","
<> "keyHash: " <> jsUint8 wrongKeyHash
<> "certChainDer: ["
<> jsUint8 leafCertDer
<> ","
<> jsUint8 idCertDer
<> "],"
<> "signedKeyDer: "
<> jsUint8 signedKeyDer
<> ","
<> "sigBytes: "
<> jsUint8 challengeSigRaw
<> ","
<> "challenge: "
<> jsUint8 challenge
<> ","
<> "sessionId: "
<> jsUint8 sessionId
<> ","
<> "keyHash: "
<> jsUint8 wrongKeyHash
<> "});"
<> jsOut "new Uint8Array([ok ? 1 : 0])"
tsResult `shouldBe` B.pack [0]
@@ -1849,12 +1928,25 @@ tsIdentityTests = describe "crypto/identity" $ do
callNode $
impId
<> "const ok = Id.verifyIdentityProof({"
<> "certChainDer: [" <> jsUint8 leafCertDer <> "," <> jsUint8 idCertDer <> "],"
<> "signedKeyDer: " <> jsUint8 signedKeyDer <> ","
<> "sigBytes: " <> jsUint8 wrongSigRaw <> ","
<> "challenge: " <> jsUint8 challenge <> ","
<> "sessionId: " <> jsUint8 sessionId <> ","
<> "keyHash: " <> jsUint8 keyHash
<> "certChainDer: ["
<> jsUint8 leafCertDer
<> ","
<> jsUint8 idCertDer
<> "],"
<> "signedKeyDer: "
<> jsUint8 signedKeyDer
<> ","
<> "sigBytes: "
<> jsUint8 wrongSigRaw
<> ","
<> "challenge: "
<> jsUint8 challenge
<> ","
<> "sessionId: "
<> jsUint8 sessionId
<> ","
<> "keyHash: "
<> jsUint8 keyHash
<> "});"
<> jsOut "new Uint8Array([ok ? 1 : 0])"
tsResult `shouldBe` B.pack [0]
@@ -1890,12 +1982,25 @@ tsIdentityTests = describe "crypto/identity" $ do
callNode $
impId
<> "const ok = Id.verifyIdentityProof({"
<> "certChainDer: [" <> jsUint8 leafCertDer <> "," <> jsUint8 idCertDer <> "],"
<> "signedKeyDer: " <> jsUint8 signedKeyDer <> ","
<> "sigBytes: " <> jsUint8 challengeSigRaw <> ","
<> "challenge: " <> jsUint8 challenge <> ","
<> "sessionId: " <> jsUint8 sessionId <> ","
<> "keyHash: " <> jsUint8 keyHash
<> "certChainDer: ["
<> jsUint8 leafCertDer
<> ","
<> jsUint8 idCertDer
<> "],"
<> "signedKeyDer: "
<> jsUint8 signedKeyDer
<> ","
<> "sigBytes: "
<> jsUint8 challengeSigRaw
<> ","
<> "challenge: "
<> jsUint8 challenge
<> ","
<> "sessionId: "
<> jsUint8 sessionId
<> ","
<> "keyHash: "
<> jsUint8 keyHash
<> "});"
<> jsOut "new Uint8Array([ok ? 1 : 0])"
tsResult `shouldBe` B.pack [0]
@@ -1919,7 +2024,9 @@ tsDescriptionTests = describe "protocol/description" $ do
tsResult <-
callNode $
impDesc
<> "const s = new TextDecoder().decode(" <> jsUint8 encoded <> ");"
<> "const s = new TextDecoder().decode("
<> jsUint8 encoded
<> ");"
<> jsOut "Desc.base64urlDecode(s)"
tsResult `shouldBe` bs
@@ -1928,7 +2035,9 @@ tsDescriptionTests = describe "protocol/description" $ do
tsResult <-
callNode $
impDesc
<> "const data = " <> jsUint8 bs <> ";"
<> "const data = "
<> jsUint8 bs
<> ";"
<> "const encoded = Desc.base64urlEncode(data);"
<> jsOut "Desc.base64urlDecode(encoded)"
tsResult `shouldBe` bs
@@ -1958,7 +2067,9 @@ tsDescriptionTests = describe "protocol/description" $ do
tsResult <-
callNode $
impDesc
<> "const yaml = new TextDecoder().decode(" <> jsUint8 fixture <> ");"
<> "const yaml = new TextDecoder().decode("
<> jsUint8 fixture
<> ");"
<> "const fd = Desc.decodeFileDescription(yaml);"
<> "const reEncoded = Desc.encodeFileDescription(fd);"
<> jsOut "new TextEncoder().encode(reEncoded)"
@@ -1969,7 +2080,9 @@ tsDescriptionTests = describe "protocol/description" $ do
tsResult <-
callNode $
impDesc
<> "const yaml = new TextDecoder().decode(" <> jsUint8 fixture <> ");"
<> "const yaml = new TextDecoder().decode("
<> jsUint8 fixture
<> ");"
<> "const fd = Desc.decodeFileDescription(yaml);"
<> "const r = ["
<> "fd.party,"
@@ -2023,7 +2136,9 @@ tsDescriptionTests = describe "protocol/description" $ do
tsResult <-
callNode $
impDesc
<> "const yaml = new TextDecoder().decode(" <> jsUint8 fixture <> ");"
<> "const yaml = new TextDecoder().decode("
<> jsUint8 fixture
<> ");"
<> "const fd = Desc.decodeFileDescription(yaml);"
<> "const r = Desc.validateFileDescription(fd);"
<> jsOut "new TextEncoder().encode(r === null ? 'ok' : r)"
@@ -2034,7 +2149,9 @@ tsDescriptionTests = describe "protocol/description" $ do
tsResult <-
callNode $
impDesc
<> "const yaml = new TextDecoder().decode(" <> jsUint8 fixture <> ");"
<> "const yaml = new TextDecoder().decode("
<> jsUint8 fixture
<> ");"
<> "const fd = Desc.decodeFileDescription(yaml);"
<> "fd.chunks[1].chunkNo = 5;"
<> "const r = Desc.validateFileDescription(fd);"
@@ -2046,7 +2163,9 @@ tsDescriptionTests = describe "protocol/description" $ do
tsResult <-
callNode $
impDesc
<> "const yaml = new TextDecoder().decode(" <> jsUint8 fixture <> ");"
<> "const yaml = new TextDecoder().decode("
<> jsUint8 fixture
<> ");"
<> "const fd = Desc.decodeFileDescription(yaml);"
<> "fd.size = 999;"
<> "const r = Desc.validateFileDescription(fd);"
@@ -2153,8 +2272,14 @@ tsClientTests = describe "protocol/client" $ do
callNode $
impCli
<> "const auth = Cli.cbAuthenticate("
<> jsUint8 pubARaw <> "," <> jsUint8 privBRaw <> ","
<> jsUint8 nonce24 <> "," <> jsUint8 msg <> ");"
<> jsUint8 pubARaw
<> ","
<> jsUint8 privBRaw
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 msg
<> ");"
<> jsOut "auth"
tsResult `shouldBe` expected
@@ -2181,9 +2306,16 @@ tsClientTests = describe "protocol/client" $ do
callNode $
impCli
<> "const valid = Cli.cbVerify("
<> jsUint8 pubBRaw <> "," <> jsUint8 privARaw <> ","
<> jsUint8 nonce24 <> "," <> jsUint8 authBytes_ <> ","
<> jsUint8 msg <> ");"
<> jsUint8 pubBRaw
<> ","
<> jsUint8 privARaw
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 authBytes_
<> ","
<> jsUint8 msg
<> ");"
<> jsOut "new Uint8Array([valid ? 1 : 0])"
tsResult `shouldBe` B.pack [1]
@@ -2200,9 +2332,16 @@ tsClientTests = describe "protocol/client" $ do
callNode $
impCli
<> "const valid = Cli.cbVerify("
<> jsUint8 pubBRaw <> "," <> jsUint8 privARaw <> ","
<> jsUint8 nonce24 <> "," <> jsUint8 authBytes_ <> ","
<> jsUint8 wrongMsg <> ");"
<> jsUint8 pubBRaw
<> ","
<> jsUint8 privARaw
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 authBytes_
<> ","
<> jsUint8 wrongMsg
<> ");"
<> jsOut "new Uint8Array([valid ? 1 : 0])"
tsResult `shouldBe` B.pack [0]
@@ -2212,8 +2351,14 @@ tsClientTests = describe "protocol/client" $ do
callNode $
impCli
<> "const auth = Cli.cbAuthenticate("
<> jsUint8 pubARaw <> "," <> jsUint8 privBRaw <> ","
<> jsUint8 nonce24 <> "," <> jsUint8 msg <> ");"
<> jsUint8 pubARaw
<> ","
<> jsUint8 privBRaw
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 msg
<> ");"
<> jsOut "auth"
let hsValid =
C.cbVerify
@@ -2238,9 +2383,12 @@ tsClientTests = describe "protocol/client" $ do
callNode $
impCli
<> "const enc = Cli.encryptTransportChunk("
<> jsUint8 dhSecretBytes <> ","
<> jsUint8 nonce24 <> ","
<> jsUint8 plaintext <> ");"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 plaintext
<> ");"
<> jsOut "enc"
tsResult `shouldBe` expected
@@ -2254,9 +2402,12 @@ tsClientTests = describe "protocol/client" $ do
callNode $
impCli
<> "const r = Cli.decryptTransportChunk("
<> jsUint8 dhSecretBytes <> ","
<> jsUint8 nonce24 <> ","
<> jsUint8 encData <> ");"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 encData
<> ");"
<> "if (!r.valid) throw new Error('invalid');"
<> jsOut "r.content"
tsResult `shouldBe` plaintext
@@ -2266,11 +2417,19 @@ tsClientTests = describe "protocol/client" $ do
tsResult <-
callNode $
impCli
<> "const plain = " <> jsUint8 plaintext <> ";"
<> "const plain = "
<> jsUint8 plaintext
<> ";"
<> "const enc = Cli.encryptTransportChunk("
<> jsUint8 dhSecretBytes <> "," <> jsUint8 nonce24 <> ",plain);"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ",plain);"
<> "const r = Cli.decryptTransportChunk("
<> jsUint8 dhSecretBytes <> "," <> jsUint8 nonce24 <> ",enc);"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ",enc);"
<> "if (!r.valid) throw new Error('invalid');"
<> jsOut "r.content"
tsResult `shouldBe` plaintext
@@ -2281,11 +2440,18 @@ tsClientTests = describe "protocol/client" $ do
callNode $
impCli
<> "const enc = Cli.encryptTransportChunk("
<> jsUint8 dhSecretBytes <> "," <> jsUint8 nonce24 <> ","
<> jsUint8 plaintext <> ");"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 plaintext
<> ");"
<> "enc[0] ^= 0xff;"
<> "const r = Cli.decryptTransportChunk("
<> jsUint8 dhSecretBytes <> "," <> jsUint8 nonce24 <> ",enc);"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ",enc);"
<> jsOut "new Uint8Array([r.valid ? 1 : 0])"
tsResult `shouldBe` B.pack [0]
@@ -2325,7 +2491,10 @@ tsDownloadTests = describe "download" $ do
callNode $
impDl
<> "const dh = Dl.processFileResponse("
<> jsUint8 privARaw <> "," <> jsUint8 pubBRaw <> ");"
<> jsUint8 privARaw
<> ","
<> jsUint8 pubBRaw
<> ");"
<> jsOut "dh"
tsDhSecret `shouldBe` hsDhBytes
@@ -2344,10 +2513,14 @@ tsDownloadTests = describe "download" $ do
callNode $
impDl
<> "const r = Dl.decryptReceivedChunk("
<> jsUint8 dhSecretBytes <> ","
<> jsUint8 nonce24 <> ","
<> jsUint8 encData <> ","
<> jsUint8 chunkDigest <> ");"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 encData
<> ","
<> jsUint8 chunkDigest
<> ");"
<> jsOut "r"
tsResult `shouldBe` chunkData
@@ -2364,10 +2537,14 @@ tsDownloadTests = describe "download" $ do
callNode $
impDl
<> "let ok = false; try { Dl.decryptReceivedChunk("
<> jsUint8 dhSecretBytes <> ","
<> jsUint8 nonce24 <> ","
<> jsUint8 encData <> ","
<> jsUint8 wrongDigest <> "); } catch(e) { ok = e.message.includes('digest'); }"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 encData
<> ","
<> jsUint8 wrongDigest
<> "); } catch(e) { ok = e.message.includes('digest'); }"
<> jsOut "new Uint8Array([ok ? 1 : 0])"
tsResult `shouldBe` B.pack [1]
@@ -2383,9 +2560,12 @@ tsDownloadTests = describe "download" $ do
callNode $
impDl
<> "const r = Dl.decryptReceivedChunk("
<> jsUint8 dhSecretBytes <> ","
<> jsUint8 nonce24 <> ","
<> jsUint8 encData <> ",null);"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 encData
<> ",null);"
<> jsOut "r"
tsResult `shouldBe` chunkData
@@ -2418,13 +2598,19 @@ tsDownloadTests = describe "download" $ do
callNode $
impDl
<> "const chunk = Dl.decryptReceivedChunk("
<> jsUint8 dhSecretBytes <> ","
<> jsUint8 nonce24 <> ","
<> jsUint8 transportEncData <> ",null);"
<> jsUint8 dhSecretBytes
<> ","
<> jsUint8 nonce24
<> ","
<> jsUint8 transportEncData
<> ",null);"
<> "const r = F.decryptChunks("
<> show encSize <> "n,[chunk],"
<> jsUint8 fileKey32 <> ","
<> jsUint8 fileNonce24 <> ");"
<> show encSize
<> "n,[chunk],"
<> jsUint8 fileKey32
<> ","
<> jsUint8 fileNonce24
<> ");"
<> "const hdrBytes = F.encodeFileHeader(r.header);"
<> jsOut "new Uint8Array([...hdrBytes, ...r.content])"
tsResult `shouldBe` (fileHdr <> source)
@@ -2470,15 +2656,26 @@ tsDownloadTests = describe "download" $ do
callNode $
impDl
<> "const c1 = Dl.decryptReceivedChunk("
<> jsUint8 dhSecret1Bytes <> "," <> jsUint8 nonce1 <> ","
<> jsUint8 transportEnc1 <> ",null);"
<> jsUint8 dhSecret1Bytes
<> ","
<> jsUint8 nonce1
<> ","
<> jsUint8 transportEnc1
<> ",null);"
<> "const c2 = Dl.decryptReceivedChunk("
<> jsUint8 dhSecret2Bytes <> "," <> jsUint8 nonce2 <> ","
<> jsUint8 transportEnc2 <> ",null);"
<> jsUint8 dhSecret2Bytes
<> ","
<> jsUint8 nonce2
<> ","
<> jsUint8 transportEnc2
<> ",null);"
<> "const r = F.decryptChunks("
<> show encSize <> "n,[c1,c2],"
<> jsUint8 fileKey32 <> ","
<> jsUint8 fileNonce24 <> ");"
<> show encSize
<> "n,[c1,c2],"
<> jsUint8 fileKey32
<> ","
<> jsUint8 fileNonce24
<> ");"
<> "const hdrBytes = F.encodeFileHeader(r.header);"
<> jsOut "new Uint8Array([...hdrBytes, ...r.content])"
tsResult `shouldBe` (fileHdr <> source)
@@ -2501,12 +2698,16 @@ tsDownloadTests = describe "download" $ do
callNode $
impDl
<> "const resp = Cmd.decodeResponse("
<> jsUint8 fileResponseBytes <> ");"
<> jsUint8 fileResponseBytes
<> ");"
<> "if (resp.type !== 'FRFile') throw new Error('expected FRFile');"
<> "const dhSecret = Dl.processFileResponse("
<> jsUint8 privBRaw <> ",resp.rcvDhKey);"
<> jsUint8 privBRaw
<> ",resp.rcvDhKey);"
<> "const r = Dl.decryptReceivedChunk(dhSecret,"
<> "resp.nonce," <> jsUint8 encData <> ",null);"
<> "resp.nonce,"
<> jsUint8 encData
<> ",null);"
<> jsOut "r"
tsResult `shouldBe` chunkData
@@ -2530,11 +2731,103 @@ tsDownloadTests = describe "download" $ do
tsResult <-
callNode $
impDl
<> "const fd = {size: " <> show encSize <> ","
<> "key: " <> jsUint8 fileKey32 <> ","
<> "nonce: " <> jsUint8 fileNonce24 <> "};"
<> "const fd = {size: "
<> show encSize
<> ","
<> "key: "
<> jsUint8 fileKey32
<> ","
<> "nonce: "
<> jsUint8 fileNonce24
<> "};"
<> "const r = Dl.processDownloadedFile(fd, ["
<> jsUint8 fileEncrypted <> "]);"
<> jsUint8 fileEncrypted
<> "]);"
<> "const hdrBytes = F.encodeFileHeader(r.header);"
<> jsOut "new Uint8Array([...hdrBytes, ...r.content])"
tsResult `shouldBe` (fileHdr <> source)
-- ── protocol/address ──────────────────────────────────────────────
tsAddressTests :: Spec
tsAddressTests = describe "protocol/address" $ do
it "parseXFTPServer with port" $ do
let addr = "xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@localhost:8000" :: String
expectedKH :: B.ByteString
expectedKH = either error id $ strDecode "LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI="
result <-
callNode $
impAddr
<> "const s = Addr.parseXFTPServer('"
<> addr
<> "');"
<> jsOut "new Uint8Array([...s.keyHash, ...new TextEncoder().encode(s.host + ':' + s.port)])"
let (kh, hostPort) = B.splitAt 32 result
kh `shouldBe` expectedKH
hostPort `shouldBe` "localhost:8000"
it "parseXFTPServer default port" $ do
result <-
callNode $
impAddr
<> "const s = Addr.parseXFTPServer('xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@example.com');"
<> jsOut "new TextEncoder().encode(s.host + ':' + s.port)"
result `shouldBe` "example.com:443"
it "parseXFTPServer multi-host takes first" $ do
result <-
callNode $
impAddr
<> "const s = Addr.parseXFTPServer('xftp://LcJUMfVhwD8yxjAiSaDzzGF3-kLG4Uh0Fl_ZIjrRwjI=@host1.com:5000,host2.com');"
<> jsOut "new TextEncoder().encode(s.host + ':' + s.port)"
result `shouldBe` "host1.com:5000"
-- ── integration ───────────────────────────────────────────────────
tsIntegrationTests :: Spec
tsIntegrationTests = describe "integration" $ do
it "web handshake with Ed25519 identity verification" $
webHandshakeTest testXFTPServerConfigEd25519SNI "tests/fixtures/ed25519/ca.crt"
it "web handshake with Ed448 identity verification" $
webHandshakeTest testXFTPServerConfigSNI "tests/fixtures/ca.crt"
webHandshakeTest :: XFTPServerConfig -> FilePath -> Expectation
webHandshakeTest cfg caFile = do
withXFTPServerCfg cfg $ \_ -> do
Fingerprint fp <- loadFileFingerprint caFile
let fpStr = map (toEnum . fromIntegral) $ B.unpack $ strEncode fp
addr = "xftp://" <> fpStr <> "@localhost:" <> xftpTestPort
result <-
callNode $
"import http2 from 'node:http2';\
\import crypto from 'node:crypto';\
\import sodium from 'libsodium-wrappers-sumo';\
\import * as Addr from './dist/protocol/address.js';\
\import * as Hs from './dist/protocol/handshake.js';\
\import * as Id from './dist/crypto/identity.js';\
\await sodium.ready;\
\const server = Addr.parseXFTPServer('"
<> addr
<> "');\
\const readBody = s => new Promise((ok, err) => {\
\const c = [];\
\s.on('data', d => c.push(d));\
\s.on('end', () => ok(Buffer.concat(c)));\
\s.on('error', err);\
\});\
\const client = http2.connect('https://' + server.host + ':' + server.port, {rejectUnauthorized: false});\
\const challenge = new Uint8Array(crypto.randomBytes(32));\
\const s1 = client.request({':method': 'POST', ':path': '/'});\
\s1.end(Buffer.from(Hs.encodeClientHello({webChallenge: challenge})));\
\const hs = Hs.decodeServerHandshake(new Uint8Array(await readBody(s1)));\
\const idOk = hs.webIdentityProof\
\ ? Id.verifyIdentityProof({certChainDer: hs.certChainDer, signedKeyDer: hs.signedKeyDer,\
\sigBytes: hs.webIdentityProof, challenge, sessionId: hs.sessionId, keyHash: server.keyHash})\
\ : false;\
\const ver = hs.xftpVersionRange.maxVersion;\
\const s2 = client.request({':method': 'POST', ':path': '/'});\
\s2.end(Buffer.from(Hs.encodeClientHandshake({xftpVersion: ver, keyHash: server.keyHash})));\
\const ack = await readBody(s2);\
\client.close();"
<> jsOut "new Uint8Array([idOk ? 1 : 0, ack.length === 0 ? 1 : 0])"
result `shouldBe` B.pack [1, 1]
+10
View File
@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBazCCAR2gAwIBAgIUSTqS4QptGQWYoukUUuYqC6iV5TMwBQYDK2VwMCoxFjAU
BgNVBAMMDVNNUCBzZXJ2ZXIgQ0ExEDAOBgNVBAoMB1NpbXBsZVgwIBcNMjYwMjAy
MDkxMTM1WhgPMjEyNjAxMDkwOTExMzVaMCoxFjAUBgNVBAMMDVNNUCBzZXJ2ZXIg
Q0ExEDAOBgNVBAoMB1NpbXBsZVgwKjAFBgMrZXADIQAv7I91vFk1tu6bj7J8HfkA
c7vjTnae9LFz+fXXtjkJVqNTMFEwHQYDVR0OBBYEFJSRDsRRvAyWhRMrXfW0Apsw
FbIHMB8GA1UdIwQYMBaAFJSRDsRRvAyWhRMrXfW0ApswFbIHMA8GA1UdEwEB/wQF
MAMBAf8wBQYDK2VwA0EAa9btje9yq4avTR8AOOkLHvGG0F6CskcGUFCkEbdCU+7I
9Qx1E8TlK6SwtLAKGi+qoK89dsdKL7rY2KbSP3SMAg==
-----END CERTIFICATE-----
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEINrfCroxhwopILZmG394xna73ethj6Z6IJSdBY2KjmW2
-----END PRIVATE KEY-----
+10
View File
@@ -0,0 +1,10 @@
-----BEGIN CERTIFICATE-----
MIIBcTCCASOgAwIBAgIUGMY4bIefHdfLBMptm/MOtg3ekGEwBQYDK2VwMCoxFjAU
BgNVBAMMDVNNUCBzZXJ2ZXIgQ0ExEDAOBgNVBAoMB1NpbXBsZVgwIBcNMjYwMjAy
MDkxMTM1WhgPMjEyNjAxMDkwOTExMzVaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAq
MAUGAytlcAMhANYHFcaIJ540sL66lt5GmPrd0HX3mogATKrnWHPWQaGmo28wbTAJ
BgNVHRMEAjAAMAsGA1UdDwQEAwIDyDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNV
HQ4EFgQUQlsiIdymULnrH8KY+N+dd5RQADMwHwYDVR0jBBgwFoAUlJEOxFG8DJaF
Eytd9bQCmzAVsgcwBQYDK2VwA0EAFXpm1Ucdoa4W1ZPE/28FRkoHeHiEfyHX0NFx
qz7fiV6ys6KnnlC+xLDX0HVLcppImdnm4qmKddCagRfE7h0zAw==
-----END CERTIFICATE-----
+3
View File
@@ -0,0 +1,3 @@
-----BEGIN PRIVATE KEY-----
MC4CAQAwBQYDK2VwBCIEIJjrvSfyWU9Xdiery1u85BK0Syw5jmxIJdzo0idiIasu
-----END PRIVATE KEY-----
+1
View File
@@ -16,6 +16,7 @@
"typescript": "^5.4.0"
},
"dependencies": {
"@noble/curves": "^1.9.7",
"libsodium-wrappers-sumo": "^0.7.13"
}
}
+27 -8
View File
@@ -2,11 +2,11 @@
//
// Verifies server identity in the XFTP web handshake using the certificate
// chain from the protocol handshake (independent of TLS certificates).
// Ed25519 verification via libsodium. Ed448 deferred.
// Ed25519 via libsodium, Ed448 via @noble/curves.
import {Decoder, concatBytes} from "../protocol/encoding.js"
import {sha256} from "./digest.js"
import {verify, decodePubKeyEd25519} from "./keys.js"
import {verify, decodePubKeyEd25519, verifyEd448, decodePubKeyEd448} from "./keys.js"
import {chainIdCaCerts, extractSignedKey} from "../protocol/handshake.js"
// ── ASN.1 DER helpers (minimal, for X.509 parsing) ─────────────────
@@ -53,9 +53,27 @@ export function extractCertPublicKeyInfo(certDer: Uint8Array): Uint8Array {
return derReadElement(d) // SubjectPublicKeyInfo
}
// Extract raw Ed25519 public key (32 bytes) from X.509 certificate DER.
export function extractCertEd25519Key(certDer: Uint8Array): Uint8Array {
return decodePubKeyEd25519(extractCertPublicKeyInfo(certDer))
// Detect certificate key algorithm from SPKI DER prefix.
// Ed25519 OID 1.3.101.112: byte 8 = 0x70, SPKI = 44 bytes
// Ed448 OID 1.3.101.113: byte 8 = 0x71, SPKI = 69 bytes
type CertKeyAlgorithm = 'ed25519' | 'ed448'
function detectKeyAlgorithm(spki: Uint8Array): CertKeyAlgorithm {
if (spki.length === 44 && spki[8] === 0x70) return 'ed25519'
if (spki.length === 69 && spki[8] === 0x71) return 'ed448'
throw new Error("unsupported certificate key algorithm")
}
// Extract raw public key from SPKI DER, auto-detecting Ed25519 or Ed448.
function extractCertRawKey(spki: Uint8Array): {key: Uint8Array, alg: CertKeyAlgorithm} {
const alg = detectKeyAlgorithm(spki)
const key = alg === 'ed25519' ? decodePubKeyEd25519(spki) : decodePubKeyEd448(spki)
return {key, alg}
}
// Verify signature using the appropriate algorithm.
function verifySig(alg: CertKeyAlgorithm, key: Uint8Array, sig: Uint8Array, msg: Uint8Array): boolean {
return alg === 'ed25519' ? verify(key, sig, msg) : verifyEd448(key, sig, msg)
}
// ── Identity proof verification ─────────────────────────────────────
@@ -79,10 +97,11 @@ export function verifyIdentityProof(v: IdentityVerification): boolean {
if (cc.type !== 'valid') return false
const fp = sha256(cc.idCert)
if (!constantTimeEqual(fp, v.keyHash)) return false
const leafKey = extractCertEd25519Key(cc.leafCert)
if (!verify(leafKey, v.sigBytes, concatBytes(v.challenge, v.sessionId))) return false
const spki = extractCertPublicKeyInfo(cc.leafCert)
const {key, alg} = extractCertRawKey(spki)
if (!verifySig(alg, key, v.sigBytes, concatBytes(v.challenge, v.sessionId))) return false
const sk = extractSignedKey(v.signedKeyDer)
return verify(leafKey, sk.signature, sk.objectDer)
return verifySig(alg, key, sk.signature, sk.objectDer)
}
function constantTimeEqual(a: Uint8Array, b: Uint8Array): boolean {
+31 -1
View File
@@ -1,6 +1,7 @@
// Key generation, signing, DH — Simplex.Messaging.Crypto (Ed25519/X25519 functions).
// Key generation, signing, DH — Simplex.Messaging.Crypto (Ed25519/X25519/Ed448 functions).
import sodium from "libsodium-wrappers-sumo"
import {ed448} from "@noble/curves/ed448"
import {sha256} from "./digest.js"
import {concatBytes} from "../protocol/encoding.js"
@@ -97,6 +98,35 @@ export function decodePubKeyX25519(der: Uint8Array): Uint8Array {
return der.subarray(12)
}
// -- DER encoding for Ed448 public keys (RFC 8410, SubjectPublicKeyInfo)
// SEQUENCE { SEQUENCE { OID 1.3.101.113 } BIT STRING { 0x00 <57 bytes> } }
const ED448_PUBKEY_DER_PREFIX = new Uint8Array([
0x30, 0x43, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x71, 0x03, 0x3a, 0x00,
])
export function encodePubKeyEd448(rawPubKey: Uint8Array): Uint8Array {
return concatBytes(ED448_PUBKEY_DER_PREFIX, rawPubKey)
}
export function decodePubKeyEd448(der: Uint8Array): Uint8Array {
if (der.length !== 69) throw new Error("decodePubKeyEd448: invalid length")
for (let i = 0; i < ED448_PUBKEY_DER_PREFIX.length; i++) {
if (der[i] !== ED448_PUBKEY_DER_PREFIX[i]) throw new Error("decodePubKeyEd448: invalid DER prefix")
}
return der.subarray(12)
}
// -- Ed448 verification via @noble/curves (Crypto.hs:1270 verify')
export function verifyEd448(publicKey: Uint8Array, sig: Uint8Array, msg: Uint8Array): boolean {
try {
return ed448.verify(sig, msg, publicKey)
} catch {
return false
}
}
// -- DER encoding for private keys (PKCS8 OneAsymmetricKey, RFC 8410)
// SEQUENCE { INTEGER 0, SEQUENCE { OID }, OCTET STRING { OCTET STRING { <32 bytes> } } }
+47
View File
@@ -0,0 +1,47 @@
// XFTP server address parsing — Simplex.Messaging.Protocol (ProtocolServer)
//
// Parses server address strings of the form:
// xftp://<keyhash>@<host>[,<host2>,...][:<port>]
//
// KeyHash is base64url-encoded SHA-256 fingerprint of the identity certificate.
export interface XFTPServer {
keyHash: Uint8Array // 32-byte SHA-256 fingerprint (decoded from base64url)
host: string // primary hostname
port: string // port number (default "443")
}
// Decode base64url (RFC 4648 §5) to Uint8Array.
function base64urlDecode(s: string): Uint8Array {
// Convert base64url to standard base64
let b64 = s.replace(/-/g, '+').replace(/_/g, '/')
// Add padding if needed
while (b64.length % 4 !== 0) b64 += '='
const bin = atob(b64)
const bytes = new Uint8Array(bin.length)
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i)
return bytes
}
// Parse an XFTP server address string.
// Format: xftp://<base64url-keyhash>@<host>[,<host2>,...][:<port>]
export function parseXFTPServer(address: string): XFTPServer {
const m = address.match(/^xftp:\/\/([A-Za-z0-9_-]+={0,2})@(.+)$/)
if (!m) throw new Error("parseXFTPServer: invalid address format")
const keyHash = base64urlDecode(m[1])
if (keyHash.length !== 32) throw new Error("parseXFTPServer: keyHash must be 32 bytes")
const hostPart = m[2]
// Take the first host (before any comma), then split port from that
const firstHost = hostPart.split(',')[0]
const colonIdx = firstHost.lastIndexOf(':')
let host: string
let port: string
if (colonIdx > 0) {
host = firstHost.substring(0, colonIdx)
port = firstHost.substring(colonIdx + 1)
} else {
host = firstHost
port = "443"
}
return {keyHash, host, port}
}
+17 -3
View File
@@ -138,7 +138,7 @@ export interface SignedKey {
objectDer: Uint8Array // raw DER of the signed object (SubjectPublicKeyInfo)
dhKey: Uint8Array // extracted 32-byte X25519 public key
algorithm: Uint8Array // AlgorithmIdentifier DER bytes
signature: Uint8Array // raw Ed25519 signature bytes (64 bytes)
signature: Uint8Array // raw signature bytes (Ed25519: 64, Ed448: 114)
}
// Parse ASN.1 DER length (short and long form).
@@ -190,8 +190,22 @@ export function extractSignedKey(signedDer: Uint8Array): SignedKey {
if (unusedBits !== 0) throw new Error("SignedExact: expected 0 unused bits in signature")
const signature = outer.take(sigLen - 1)
// Extract X25519 key from SubjectPublicKeyInfo
const dhKey = decodePubKeyX25519(objectDer)
// Extract X25519 key from the signed object.
// objectDer may be the raw SPKI (44 bytes) or a wrapper SEQUENCE
// from x509 objectToSignedExact which wraps toASN1 in Start Sequence.
const dhKey = decodeX25519Key(objectDer)
return {objectDer, dhKey, algorithm, signature}
}
// Extract X25519 raw public key from either direct SPKI (44 bytes)
// or a wrapper SEQUENCE containing the SPKI.
function decodeX25519Key(der: Uint8Array): Uint8Array {
if (der.length === 44) return decodePubKeyX25519(der)
if (der[0] !== 0x30) throw new Error("decodeX25519Key: expected SEQUENCE")
const d = new Decoder(der)
d.anyByte()
derLength(d)
const inner = derElement(d)
return decodePubKeyX25519(inner)
}