mirror of
https://github.com/simplex-chat/simplexmq.git
synced 2026-08-28 02:54:51 +00:00
test for xftp web handshake
This commit is contained in:
@@ -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 145–161)
|
||||
|
||||
- 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
@@ -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
@@ -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]
|
||||
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBazCCAR2gAwIBAgIUSTqS4QptGQWYoukUUuYqC6iV5TMwBQYDK2VwMCoxFjAU
|
||||
BgNVBAMMDVNNUCBzZXJ2ZXIgQ0ExEDAOBgNVBAoMB1NpbXBsZVgwIBcNMjYwMjAy
|
||||
MDkxMTM1WhgPMjEyNjAxMDkwOTExMzVaMCoxFjAUBgNVBAMMDVNNUCBzZXJ2ZXIg
|
||||
Q0ExEDAOBgNVBAoMB1NpbXBsZVgwKjAFBgMrZXADIQAv7I91vFk1tu6bj7J8HfkA
|
||||
c7vjTnae9LFz+fXXtjkJVqNTMFEwHQYDVR0OBBYEFJSRDsRRvAyWhRMrXfW0Apsw
|
||||
FbIHMB8GA1UdIwQYMBaAFJSRDsRRvAyWhRMrXfW0ApswFbIHMA8GA1UdEwEB/wQF
|
||||
MAMBAf8wBQYDK2VwA0EAa9btje9yq4avTR8AOOkLHvGG0F6CskcGUFCkEbdCU+7I
|
||||
9Qx1E8TlK6SwtLAKGi+qoK89dsdKL7rY2KbSP3SMAg==
|
||||
-----END CERTIFICATE-----
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEINrfCroxhwopILZmG394xna73ethj6Z6IJSdBY2KjmW2
|
||||
-----END PRIVATE KEY-----
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
-----BEGIN CERTIFICATE-----
|
||||
MIIBcTCCASOgAwIBAgIUGMY4bIefHdfLBMptm/MOtg3ekGEwBQYDK2VwMCoxFjAU
|
||||
BgNVBAMMDVNNUCBzZXJ2ZXIgQ0ExEDAOBgNVBAoMB1NpbXBsZVgwIBcNMjYwMjAy
|
||||
MDkxMTM1WhgPMjEyNjAxMDkwOTExMzVaMBQxEjAQBgNVBAMMCWxvY2FsaG9zdDAq
|
||||
MAUGAytlcAMhANYHFcaIJ540sL66lt5GmPrd0HX3mogATKrnWHPWQaGmo28wbTAJ
|
||||
BgNVHRMEAjAAMAsGA1UdDwQEAwIDyDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNV
|
||||
HQ4EFgQUQlsiIdymULnrH8KY+N+dd5RQADMwHwYDVR0jBBgwFoAUlJEOxFG8DJaF
|
||||
Eytd9bQCmzAVsgcwBQYDK2VwA0EAFXpm1Ucdoa4W1ZPE/28FRkoHeHiEfyHX0NFx
|
||||
qz7fiV6ys6KnnlC+xLDX0HVLcppImdnm4qmKddCagRfE7h0zAw==
|
||||
-----END CERTIFICATE-----
|
||||
Vendored
+3
@@ -0,0 +1,3 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MC4CAQAwBQYDK2VwBCIEIJjrvSfyWU9Xdiery1u85BK0Syw5jmxIJdzo0idiIasu
|
||||
-----END PRIVATE KEY-----
|
||||
@@ -16,6 +16,7 @@
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/curves": "^1.9.7",
|
||||
"libsodium-wrappers-sumo": "^0.7.13"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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> } } }
|
||||
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user