packages feed

keynub-licdongle (empty) → 1.1.1

raw patch · 7 files changed

+1212/−0 lines, 7 filesdep +Win32dep +basedep +bytestring

Dependencies added: Win32, base, bytestring, directory, filepath, keynub-licdongle, process, unix

Files

+ CHANGELOG.md view
@@ -0,0 +1,3 @@+## 1.1.1++- First release on Hackage, at the version of the SDK it belongs to.
+ LICENSE view
@@ -0,0 +1,201 @@+                                 Apache License+                           Version 2.0, January 2004+                        http://www.apache.org/licenses/++   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION++   1. Definitions.++      "License" shall mean the terms and conditions for use, reproduction,+      and distribution as defined by Sections 1 through 9 of this document.++      "Licensor" shall mean the copyright owner or entity authorized by+      the copyright owner that is granting the License.++      "Legal Entity" shall mean the union of the acting entity and all+      other entities that control, are controlled by, or are under common+      control with that entity. For the purposes of this definition,+      "control" means (i) the power, direct or indirect, to cause the+      direction or management of such entity, whether by contract or+      otherwise, or (ii) ownership of fifty percent (50%) or more of the+      outstanding shares, or (iii) beneficial ownership of such entity.++      "You" (or "Your") shall mean an individual or Legal Entity+      exercising permissions granted by this License.++      "Source" form shall mean the preferred form for making modifications,+      including but not limited to software source code, documentation+      source, and configuration files.++      "Object" form shall mean any form resulting from mechanical+      transformation or translation of a Source form, including but+      not limited to compiled object code, generated documentation,+      and conversions to other media types.++      "Work" shall mean the work of authorship, whether in Source or+      Object form, made available under the License, as indicated by a+      copyright notice that is included in or attached to the work+      (an example is provided in the Appendix below).++      "Derivative Works" shall mean any work, whether in Source or Object+      form, that is based on (or derived from) the Work and for which the+      editorial revisions, annotations, elaborations, or other modifications+      represent, as a whole, an original work of authorship. For the purposes+      of this License, Derivative Works shall not include works that remain+      separable from, or merely link (or bind by name) to the interfaces of,+      the Work and Derivative Works thereof.++      "Contribution" shall mean any work of authorship, including+      the original version of the Work and any modifications or additions+      to that Work or Derivative Works thereof, that is intentionally+      submitted to Licensor for inclusion in the Work by the copyright owner+      or by an individual or Legal Entity authorized to submit on behalf of+      the copyright owner. For the purposes of this definition, "submitted"+      means any form of electronic, verbal, or written communication sent+      to the Licensor or its representatives, including but not limited to+      communication on electronic mailing lists, source code control systems,+      and issue tracking systems that are managed by, or on behalf of, the+      Licensor for the purpose of discussing and improving the Work, but+      excluding communication that is conspicuously marked or otherwise+      designated in writing by the copyright owner as "Not a Contribution."++      "Contributor" shall mean Licensor and any individual or Legal Entity+      on behalf of whom a Contribution has been received by Licensor and+      subsequently incorporated within the Work.++   2. Grant of Copyright License. Subject to the terms and conditions of+      this License, each Contributor hereby grants to You a perpetual,+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable+      copyright license to reproduce, prepare Derivative Works of,+      publicly display, publicly perform, sublicense, and distribute the+      Work and such Derivative Works in Source or Object form.++   3. Grant of Patent License. Subject to the terms and conditions of+      this License, each Contributor hereby grants to You a perpetual,+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable+      (except as stated in this section) patent license to make, have made,+      use, offer to sell, sell, import, and otherwise transfer the Work,+      where such license applies only to those patent claims licensable+      by such Contributor that are necessarily infringed by their+      Contribution(s) alone or by combination of their Contribution(s)+      with the Work to which such Contribution(s) was submitted. If You+      institute patent litigation against any entity (including a+      cross-claim or counterclaim in a lawsuit) alleging that the Work+      or a Contribution incorporated within the Work constitutes direct+      or contributory patent infringement, then any patent licenses+      granted to You under this License for that Work shall terminate+      as of the date such litigation is filed.++   4. Redistribution. You may reproduce and distribute copies of the+      Work or Derivative Works thereof in any medium, with or without+      modifications, and in Source or Object form, provided that You+      meet the following conditions:++      (a) You must give any other recipients of the Work or+          Derivative Works a copy of this License; and++      (b) You must cause any modified files to carry prominent notices+          stating that You changed the files; and++      (c) You must retain, in the Source form of any Derivative Works+          that You distribute, all copyright, patent, trademark, and+          attribution notices from the Source form of the Work,+          excluding those notices that do not pertain to any part of+          the Derivative Works; and++      (d) If the Work includes a "NOTICE" text file as part of its+          distribution, then any Derivative Works that You distribute must+          include a readable copy of the attribution notices contained+          within such NOTICE file, excluding those notices that do not+          pertain to any part of the Derivative Works, in at least one+          of the following places: within a NOTICE text file distributed+          as part of the Derivative Works; within the Source form or+          documentation, if provided along with the Derivative Works; or,+          within a display generated by the Derivative Works, if and+          wherever such third-party notices normally appear. The contents+          of the NOTICE file are for informational purposes only and+          do not modify the License. You may add Your own attribution+          notices within Derivative Works that You distribute, alongside+          or as an addendum to the NOTICE text from the Work, provided+          that such additional attribution notices cannot be construed+          as modifying the License.++      You may add Your own copyright statement to Your modifications and+      may provide additional or different license terms and conditions+      for use, reproduction, or distribution of Your modifications, or+      for any such Derivative Works as a whole, provided Your use,+      reproduction, and distribution of the Work otherwise complies with+      the conditions stated in this License.++   5. Submission of Contributions. Unless You explicitly state otherwise,+      any Contribution intentionally submitted for inclusion in the Work+      by You to the Licensor shall be under the terms and conditions of+      this License, without any additional terms or conditions.+      Notwithstanding the above, nothing herein shall supersede or modify+      the terms of any separate license agreement you may have executed+      with Licensor regarding such Contributions.++   6. Trademarks. This License does not grant permission to use the trade+      names, trademarks, service marks, or product names of the Licensor,+      except as required for reasonable and customary use in describing the+      origin of the Work and reproducing the content of the NOTICE file.++   7. Disclaimer of Warranty. Unless required by applicable law or+      agreed to in writing, Licensor provides the Work (and each+      Contributor provides its Contributions) on an "AS IS" BASIS,+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or+      implied, including, without limitation, any warranties or conditions+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A+      PARTICULAR PURPOSE. You are solely responsible for determining the+      appropriateness of using or redistributing the Work and assume any+      risks associated with Your exercise of permissions under this License.++   8. Limitation of Liability. In no event and under no legal theory,+      whether in tort (including negligence), contract, or otherwise,+      unless required by applicable law (such as deliberate and grossly+      negligent acts) or agreed to in writing, shall any Contributor be+      liable to You for damages, including any direct, indirect, special,+      incidental, or consequential damages of any character arising as a+      result of this License or out of the use or inability to use the+      Work (including but not limited to damages for loss of goodwill,+      work stoppage, computer failure or malfunction, or any and all+      other commercial damages or losses), even if such Contributor+      has been advised of the possibility of such damages.++   9. Accepting Warranty or Additional Liability. While redistributing+      the Work or Derivative Works thereof, You may choose to offer,+      and charge a fee for, acceptance of support, warranty, indemnity,+      or other liability obligations and/or rights consistent with this+      License. However, in accepting such obligations, You may act only+      on Your own behalf and on Your sole responsibility, not on behalf+      of any other Contributor, and only if You agree to indemnify,+      defend, and hold each Contributor harmless for any liability+      incurred by, or claims asserted against, such Contributor by reason+      of your accepting any such warranty or additional liability.++   END OF TERMS AND CONDITIONS++   APPENDIX: How to apply the Apache License to your work.++      To apply the Apache License to your work, attach the following+      boilerplate notice, with the fields enclosed by brackets "[]"+      replaced with your own identifying information. (Don't include+      the brackets!)  The text should be enclosed in the appropriate+      comment syntax for the file format. We also recommend that a+      file or class name and description of purpose be included on the+      same "printed page" as the copyright notice for easier+      identification within third-party archives.++   Copyright [yyyy] [name of copyright owner]++   Licensed under the Apache License, Version 2.0 (the "License");+   you may not use this file except in compliance with the License.+   You may obtain a copy of the License at++       http://www.apache.org/licenses/LICENSE-2.0++   Unless required by applicable law or agreed to in writing, software+   distributed under the License is distributed on an "AS IS" BASIS,+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.+   See the License for the specific language governing permissions and+   limitations under the License.
+ README.md view
@@ -0,0 +1,82 @@+# KeyNub License Dongle — Haskell package++```haskell+import KeyNub.LicDongle++main :: IO ()+main = withDongle open $ \d -> do            -- first dongle, or openSerial "..."+  _ <- verifyGenuine d                        -- throws unless genuine+  secret <- withSession d $                   -- closed on every exit path+    appDecrypt d sealed                       -- <- build the licence check on this+  ...+```++**Pure Haskell.** The package calls the SDK's flat companion API through+function pointers resolved at run time, so nothing is linked at build time,+there is no C to compile and nothing sits in the path of the check that a+customer could substitute. `base`, `bytestring`, `directory`, `filepath` and+the platform's loader package (`unix`, or `Win32` on Windows) are the+dependencies. GHC 9.4 or later, on Windows, Linux and macOS.++## Setup++```+cabal install keynub-licdongle+```++or `keynub-licdongle` in `build-depends`. The package does not carry the+native library. Take `keynub_licdongle_flat` for your platform from the SDK's+[natives folder](https://github.com/AB-KeyNub/KeyNub-SDK/blob/master/NATIVES.md)+and either put it where the operating system finds libraries (next to the+executable, or on `PATH`, `LD_LIBRARY_PATH`, `DYLD_LIBRARY_PATH`), or name it+before the first call:++```haskell+setLibraryPath "/opt/keynub/libkeynub_licdongle_flat.so"+```++`KEYNUB_LICDONGLE_FLAT_LIBRARY` in the environment does the same. In a clone+of the SDK repository the package finds `natives/<platform>/` on its own, from+the working directory upwards, so the samples run with nothing set. A process+loads the library once; `loadedLibraryPath` tells which. On Linux, install the+udev rule described in+[`NATIVES.md`](https://github.com/AB-KeyNub/KeyNub-SDK/blob/master/NATIVES.md)+so the dongle is accessible without root.++## Notes++- Results are records (`Info`, `GenuineResult`, `Device`, `RecordInfo`); byte+  data is `ByteString`. The package calls the SDK's flat API: integer handles+  and caller-provided buffers, no C structures and no hand-written layouts.+- Failures throw `LicDongleError` with `errorStatus` (`NoDevice`,+  `NotGenuine`, `AuthRequired`, ...), `errorCode`, `errorOperation` and+  `errorDetail`; loading problems throw `LibraryError`.+- `isGenuine` is the non-throwing form for a gate and **fails closed**: every+  failure gives `False`.+- `withDongle` and `withSession` are `bracket`s: the dongle and the session+  are closed on every exit path, exceptions included.+- `eraseAllRecords` is deliberately separate from `eraseRecord`: an+  accidentally empty name must not wipe the dongle.+- Records are transferred in one call; the flat API has no progress+  reporting.++> Read [`docs/integration-security.md`](https://github.com/AB-KeyNub/KeyNub-SDK/blob/master/docs/integration-security.md)+> before writing the check. `ok <- isGenuine d; unless ok exitFailure` is one+> conditional branch, and patching one of those in a release binary is a+> beginner exercise. Route something the program needs through `appEncrypt` and+> `appDecrypt`, so removing the check removes the data.++## Tests++`cabal test` runs without a dongle: it compiles a stand-in for the flat C API+(`bindings/flat/licd_flat.c` over `bindings/julia/test/stub/licd_stub.c`) with+the C compiler on the path and exercises every call against it. In a package+taken from Hackage the SDK sources are not present; point `KEYNUB_SDK_ROOT` at+a clone, or `KEYNUB_LICDONGLE_FLAT_LIBRARY` at a compiled stand-in.++## Links++- [KeyNub License Dongle for Haskell](https://www.keynub.com/developers/haskell/): the product, and how to+  order one+- [Source, samples and issue tracker](https://github.com/AB-KeyNub/KeyNub-SDK) on GitHub+- [Native library for your platform](https://github.com/AB-KeyNub/KeyNub-SDK/blob/master/NATIVES.md)
+ keynub-licdongle.cabal view
@@ -0,0 +1,55 @@+cabal-version:      3.0+name:               keynub-licdongle+version:            1.1.1+synopsis:           Client for the KeyNub USB license dongle+description:+    Verify that a KeyNub USB license dongle is genuine, read and write the+    license records it stores, use its hardware counters and encrypt data so+    that only a dongle can decrypt it. Pure Haskell over the SDK's flat C API:+    the native library is loaded at run time, nothing is linked.+homepage:           https://www.keynub.com/developers/haskell/+bug-reports:        https://github.com/AB-KeyNub/KeyNub-SDK/issues+license:            Apache-2.0+license-file:       LICENSE+author:             KeyNub+maintainer:         info@keynub.com+copyright:          2026 KeyNub+category:           Hardware+build-type:         Simple+extra-doc-files:    README.md+                    CHANGELOG.md+tested-with:        GHC == 9.12.4++source-repository head+  type:     git+  location: https://github.com/AB-KeyNub/KeyNub-SDK.git+  subdir:   bindings/haskell++common shared+  default-language: Haskell2010+  ghc-options:      -Wall+  build-depends:    base >= 4.14 && < 5++library+  import:           shared+  hs-source-dirs:   src+  exposed-modules:  KeyNub.LicDongle+                    KeyNub.LicDongle.Library+  build-depends:    bytestring >= 0.10 && < 0.13,+                    directory >= 1.3 && < 1.4,+                    filepath >= 1.4 && < 1.6+  if os(windows)+    build-depends:  Win32 >= 2.6 && < 2.15+  else+    build-depends:  unix >= 2.7 && < 2.9++test-suite stub+  import:           shared+  type:             exitcode-stdio-1.0+  hs-source-dirs:   test+  main-is:          StubTest.hs+  build-depends:    keynub-licdongle,+                    bytestring,+                    directory,+                    filepath,+                    process >= 1.6 && < 1.7
+ src/KeyNub/LicDongle.hs view
@@ -0,0 +1,436 @@+{-# LANGUAGE ScopedTypeVariables #-}+-- | KeyNub License Dongle for Haskell.+--+-- Calls the SDK's flat companion API (@keynub_licdongle_flat@) through+-- function pointers resolved at run time, so nothing is linked at build time+-- and the package is pure Haskell. Handles are 'Dongle' values; byte data is+-- 'ByteString'; every failure is a 'LicDongleError' exception carrying the+-- library's status code, the operation and its diagnostic detail.+--+-- @+-- import KeyNub.LicDongle+--+-- main = withDongle open $ \\d -> do+--   _ <- verifyGenuine d                 -- throws unless genuine+--   secret <- withSession d $ appDecrypt d sealed   -- build the licence check on this+--   ...+-- @+module KeyNub.LicDongle+  ( -- * The native library+    libraryVersion+  , libraryPath+  , loadedLibraryPath+  , setLibraryPath+  , statusText+    -- * Failures+  , Status(..)+  , statusFromCode+  , LicDongleError(..)+  , LibraryError(..)+    -- * Dongles+  , Device(..)+  , devices+  , Dongle+  , open+  , openSerial+  , openPath+  , close+  , withDongle+  , serial+  , Info(..)+  , info+  , lastErrorDetail+    -- * Authenticity+  , GenuineResult(..)+  , verifyGenuine+  , isGenuine+  , setTrustRoot+    -- * Session+  , sessionOpen+  , sessionClose+  , withSession+  , authorizeWrite+  , rotateWriteKey+    -- * Records+  , RecordInfo(..)+  , records+  , readRecord+  , writeRecord+  , eraseRecord+  , eraseAllRecords+    -- * Counters+  , readCounter+  , incrementCounter+    -- * Application data+  , Scope(..)+  , appEncrypt+  , appDecrypt+  ) where++import Control.Exception (Exception(..), SomeException, bracket, bracket_, catch, throwIO)+import Control.Monad (forM, when)+import Data.Bits ((.&.))+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import Data.Word (Word8)+import Foreign.C.String (CString, peekCString, withCString)+import Foreign.C.Types (CInt(..))+import Foreign.Marshal.Alloc (alloca, allocaBytes)+import Foreign.Marshal.Array (advancePtr, allocaArray, peekArray)+import Foreign.Ptr (Ptr, castPtr, nullPtr)+import Foreign.Storable (peek, poke, pokeByteOff)++import KeyNub.LicDongle.Library++-- | The library's status codes, by name.+data Status+  = InvalidArg | NoDevice | AccessDenied | IOFailure | Timeout | ProtocolError+  | NotGenuine | CertInvalid | SessionExpired | TagMismatch | Range | StorageFull+  | Busy | NotFound | AuthRequired | FirmwareIncompatible | SdkTooOld | Cancelled+  | NotImplemented | Internal+  | Unknown Int   -- ^ a code this binding does not know+  deriving (Eq, Show)++-- | The 'Status' for a raw status code.+statusFromCode :: Int -> Status+statusFromCode c = case c of+  -1 -> InvalidArg;  -2 -> NoDevice;      -3 -> AccessDenied;   -4 -> IOFailure+  -5 -> Timeout;     -6 -> ProtocolError; -7 -> NotGenuine;     -8 -> CertInvalid+  -9 -> SessionExpired; -10 -> TagMismatch; -11 -> Range;       -12 -> StorageFull+  -13 -> Busy;       -14 -> NotFound;     -15 -> AuthRequired;  -16 -> FirmwareIncompatible+  -17 -> SdkTooOld;  -18 -> Cancelled;    -19 -> NotImplemented; -20 -> Internal+  _ -> Unknown c++-- | A failed call: the status, its raw code, the C function that failed and+-- the library's diagnostic detail for it (often empty).+data LicDongleError = LicDongleError+  { errorStatus    :: Status+  , errorCode      :: Int+  , errorOperation :: String+  , errorDetail    :: String+  } deriving (Eq, Show)++instance Exception LicDongleError where+  displayException e =+    errorOperation e ++ ": " ++ show (errorStatus e) ++ " (" ++ show (errorCode e) ++ ")"+    ++ (if null (errorDetail e) then "" else ": " ++ errorDetail e)++-- | An attached dongle, as listed by 'devices'.+data Device = Device+  { deviceSerial :: String   -- ^ the serial, as hex+  , devicePath   :: String   -- ^ the device path 'openPath' takes+  } deriving (Eq, Show)++-- | An open dongle. The library holds up to 32 at a time; release them with+-- 'close' or use 'withDongle'.+newtype Dongle = Dongle CInt+  deriving (Eq, Show)++-- | Plaintext device information, read without a session.+data Info = Info+  { protocolVersion    :: (Int, Int)+  , firmwareVersion    :: (Int, Int, Int)+  , secureElementReady :: Bool+  , provisioned        :: Bool+  , watchdogReboot     :: Bool   -- ^ the previous boot ended in a watchdog reset+  , isolated           :: Bool+  , writeAuthRotated   :: Bool   -- ^ 'rotateWriteKey' has replaced the shipped write key+  , dataCapacity       :: Int    -- ^ record storage, bytes+  , dataFree           :: Int+  } deriving (Eq, Show)++-- | What a genuine dongle proves: the serial from its certificate and the day+-- it was personalised (@YYYY-MM-DD@, or empty).+data GenuineResult = GenuineResult+  { genuineSerial   :: String+  , provisionedDate :: String+  } deriving (Eq, Show)++-- | A record on the dongle.+data RecordInfo = RecordInfo+  { recordName :: String+  , recordSize :: Int+  } deriving (Eq, Show)++-- | Who can decrypt data sealed with 'appEncrypt'.+data Scope+  = DeviceScope     -- ^ only the dongle that sealed it+  | DeveloperScope  -- ^ any dongle issued to the same developer+  deriving (Eq, Show)++serialSize, dateSize, pathSize, errorSize :: Int+serialSize = 15+dateSize = 11+pathSize = 512+errorSize = 256++rangeCode :: CInt+rangeCode = -11++-- Failure handling: every flat call returns 0 or a negative status.+check :: Maybe Dongle -> String -> CInt -> IO ()+check h op rc+  | rc == 0 = pure ()+  | otherwise = do+      detail <- maybe (pure "") lastErrorDetail h+      throwIO (LicDongleError (statusFromCode (fromIntegral rc)) (fromIntegral rc) op detail)++withOutInt :: (Ptr CInt -> IO CInt) -> IO (CInt, CInt)+withOutInt f = alloca $ \p -> do+  poke p 0+  rc <- f p+  v <- peek p+  pure (rc, v)++-- A NUL-terminated string into a caller buffer of the given capacity.+readString :: Int -> (CString -> CInt -> IO CInt) -> IO (CInt, String)+readString cap f = allocaBytes cap $ \buf -> do+  pokeByteOff buf 0 (0 :: Word8)+  rc <- f buf (fromIntegral cap)+  s <- if rc == 0 then peekCString buf else pure ""+  pure (rc, s)++-- Bytes of unknown length: ask with a capacity of 0, the library answers+-- Range and the size needed, then read into a buffer of that size.+readBytes :: Maybe Dongle -> String -> (Ptr Word8 -> CInt -> Ptr CInt -> IO CInt) -> IO ByteString+readBytes h op f = do+  (rc0, need) <- withOutInt (f nullPtr 0)+  if rc0 == 0+    then pure BS.empty+    else if rc0 /= rangeCode+      then check h op rc0 >> pure BS.empty+      else allocaBytes (fromIntegral need) $ \buf -> do+        (rc, len) <- withOutInt (f buf need)+        check h op rc+        BS.packCStringLen (castPtr buf, fromIntegral len)++useBytes :: ByteString -> (Ptr Word8 -> CInt -> IO a) -> IO a+useBytes bs f = BS.useAsCStringLen bs $ \(p, n) -> f (castPtr p) (fromIntegral n)++-- | @(major, minor, patch)@ of the loaded native library; loads it if no other+-- call has.+libraryVersion :: IO (Int, Int, Int)+libraryVersion = do+  a <- api+  allocaArray 3 $ \arr -> do+    rc <- fVersion a arr (advancePtr arr 1) (advancePtr arr 2)+    check Nothing "licdf_version" rc+    [x, y, z] <- map fromIntegral <$> peekArray 3 arr+    pure (x, y, z)++-- | The library's text for a status code.+statusText :: Int -> IO String+statusText code = do+  a <- api+  snd <$> readString errorSize (fStrerror a (fromIntegral code))++-- | The library's diagnostic detail for the most recent failure on a dongle.+lastErrorDetail :: Dongle -> IO String+lastErrorDetail (Dongle h) = do+  a <- api+  snd <$> readString errorSize (fLastError a h)++-- | The attached dongles.+devices :: IO [Device]+devices = do+  a <- api+  (rc, n) <- withOutInt (fDeviceCount a)+  check Nothing "licdf_device_count" rc+  forM [0 .. n - 1] $ \i -> do+    (rc1, s) <- readString pathSize (fDeviceSerial a i)+    check Nothing "licdf_device_serial" rc1+    (rc2, p) <- readString pathSize (fDevicePath a i)+    check Nothing "licdf_device_path" rc2+    pure (Device s p)++-- | Opens the first dongle.+open :: IO Dongle+open = openSerial ""++-- | Opens the dongle with that serial (an empty serial means the first one).+openSerial :: String -> IO Dongle+openSerial s = do+  a <- api+  h <- withCString s (fOpen a)+  handleOf "licdf_open" h++-- | Opens the dongle at a device path from 'devices'.+openPath :: String -> IO Dongle+openPath p = do+  a <- api+  h <- withCString p (fOpenPath a)+  handleOf "licdf_open_path" h++handleOf :: String -> CInt -> IO Dongle+handleOf op h+  | h > 0 = pure (Dongle h)+  | otherwise = throwIO (LicDongleError (statusFromCode (fromIntegral h)) (fromIntegral h) op "")++-- | Releases a dongle; its session ends with it.+close :: Dongle -> IO ()+close (Dongle h) = do+  a <- api+  fClose a h >>= check Nothing "licdf_close"++-- | Opens a dongle, runs the action, and closes the dongle on every exit path.+withDongle :: IO Dongle -> (Dongle -> IO a) -> IO a+withDongle acquire = bracket acquire close++-- | The dongle's serial, as hex.+serial :: Dongle -> IO String+serial d@(Dongle h) = do+  a <- api+  (rc, s) <- readString serialSize (fGetSerial a h)+  check (Just d) "licdf_get_serial" rc+  pure s++-- | Plaintext device information.+info :: Dongle -> IO Info+info d@(Dongle h) = do+  a <- api+  allocaArray 8 $ \arr -> do+    let p i = advancePtr arr i+    rc <- fGetInfo a h (p 0) (p 1) (p 2) (p 3) (p 4) (p 5) (p 6) (p 7)+    check (Just d) "licdf_get_info" rc+    v <- map fromIntegral <$> peekArray 8 arr+    let flags = v !! 5 :: Int+        flag b = flags .&. b /= 0+    pure Info+      { protocolVersion = (v !! 0, v !! 1)+      , firmwareVersion = (v !! 2, v !! 3, v !! 4)+      , secureElementReady = flag 0x01+      , provisioned = flag 0x02+      , watchdogReboot = flag 0x04+      , isolated = flag 0x08+      , writeAuthRotated = flag 0x10+      , dataCapacity = v !! 6+      , dataFree = v !! 7+      }++-- | Proves that the dongle is genuine: certificate chain to the trusted root+-- and a live challenge-response. Throws unless it is.+verifyGenuine :: Dongle -> IO GenuineResult+verifyGenuine d@(Dongle h) = do+  a <- api+  alloca $ \pg -> allocaBytes serialSize $ \ps -> allocaBytes dateSize $ \pd -> do+    poke pg 0+    pokeByteOff ps 0 (0 :: Word8)+    pokeByteOff pd 0 (0 :: Word8)+    rc <- fVerifyGenuine a h pg ps (fromIntegral serialSize) pd (fromIntegral dateSize)+    check (Just d) "licdf_verify_genuine" rc+    g <- peek pg+    when (g == 0) $ throwIO (LicDongleError NotGenuine (-7) "licdf_verify_genuine" "")+    GenuineResult <$> peekCString ps <*> peekCString pd++-- | 'True' only when the dongle proves genuine. Every failure, of any kind,+-- gives 'False'.+isGenuine :: Dongle -> IO Bool+isGenuine d = (verifyGenuine d >> pure True) `catch` \(_ :: SomeException) -> pure False++-- | Replaces the CA root that 'verifyGenuine' checks against (a certificate in+-- DER form). Applications do not need this.+setTrustRoot :: Dongle -> ByteString -> IO ()+setTrustRoot d@(Dongle h) der = do+  a <- api+  useBytes der (fSetTrustRoot a h) >>= check (Just d) "licdf_set_trust_root"++-- | Opens the encrypted session that records, counters and application-data+-- encryption need.+sessionOpen :: Dongle -> IO ()+sessionOpen d@(Dongle h) = do+  a <- api+  fSessionOpen a h >>= check (Just d) "licdf_session_open"++-- | Ends the session; the write role ends with it.+sessionClose :: Dongle -> IO ()+sessionClose d@(Dongle h) = do+  a <- api+  fSessionClose a h >>= check (Just d) "licdf_session_close"++-- | Runs the action inside a session, closing it on every exit path.+withSession :: Dongle -> IO a -> IO a+withSession d = bracket_ (sessionOpen d) (sessionClose d)++-- | Elevates the session to the write role with the dongle's write key, a+-- P-256 private key in PKCS#8 DER form.+authorizeWrite :: Dongle -> ByteString -> IO ()+authorizeWrite d@(Dongle h) key = do+  a <- api+  useBytes key (fWriteAuth a h) >>= check (Just d) "licdf_write_auth"++-- | Replaces the dongle's write key with the given one (P-256, PKCS#8 DER).+-- From the next session on, only that key elevates. Needs the write role.+rotateWriteKey :: Dongle -> ByteString -> IO ()+rotateWriteKey d@(Dongle h) key = do+  a <- api+  useBytes key (fWriteAuthRotate a h) >>= check (Just d) "licdf_write_auth_rotate"++-- | The records on the dongle. Needs a session.+records :: Dongle -> IO [RecordInfo]+records d@(Dongle h) = do+  a <- api+  (rc, n) <- withOutInt (fRecordCount a h)+  check (Just d) "licdf_record_count" rc+  forM [0 .. n - 1] $ \i -> allocaBytes pathSize $ \buf -> alloca $ \psz -> do+    pokeByteOff buf 0 (0 :: Word8)+    poke psz 0+    rc1 <- fRecordName a h i buf (fromIntegral pathSize) psz+    check (Just d) "licdf_record_name" rc1+    RecordInfo <$> peekCString buf <*> (fromIntegral <$> peek psz)++-- | Reads a record. Needs a session.+readRecord :: Dongle -> String -> IO ByteString+readRecord d@(Dongle h) name = do+  a <- api+  withCString name $ \cn -> readBytes (Just d) "licdf_record_read" (fRecordRead a h cn)++-- | Replaces (or creates) a record. Needs the write role.+writeRecord :: Dongle -> String -> ByteString -> IO ()+writeRecord d@(Dongle h) name bytes = do+  a <- api+  withCString name $ \cn -> useBytes bytes (fRecordWrite a h cn) >>= check (Just d) "licdf_record_write"++-- | Erases one record. Needs the write role. An empty name is refused; use+-- 'eraseAllRecords' to erase everything.+eraseRecord :: Dongle -> String -> IO ()+eraseRecord d@(Dongle h) name = do+  a <- api+  withCString name (fRecordErase a h) >>= check (Just d) "licdf_record_erase"++-- | Erases every record. Needs the write role.+eraseAllRecords :: Dongle -> IO ()+eraseAllRecords d@(Dongle h) = do+  a <- api+  fRecordEraseAll a h >>= check (Just d) "licdf_record_erase_all"++-- | Reads a hardware monotonic counter (0 or 1). Needs a session.+readCounter :: Dongle -> Int -> IO Int+readCounter d@(Dongle h) i = do+  a <- api+  (rc, v) <- withOutInt (fCounterRead a h (fromIntegral i))+  check (Just d) "licdf_counter_read" rc+  pure (fromIntegral v)++-- | Increments a counter, irreversibly, and gives the new value. Needs the+-- write role.+incrementCounter :: Dongle -> Int -> IO Int+incrementCounter d@(Dongle h) i = do+  a <- api+  (rc, v) <- withOutInt (fCounterIncrement a h (fromIntegral i))+  check (Just d) "licdf_counter_increment" rc+  pure (fromIntegral v)++-- | Seals data so that only a dongle can open it. Needs a session. This is the+-- pair to build a licence check on: route something the program needs through+-- it, so that removing the check removes the data.+appEncrypt :: Dongle -> Scope -> ByteString -> IO ByteString+appEncrypt d@(Dongle h) scope plain = do+  a <- api+  let code = case scope of DeviceScope -> 0; DeveloperScope -> 1+  useBytes plain $ \pp n -> readBytes (Just d) "licdf_app_encrypt" (fAppEncrypt a h code pp n)++-- | Opens data sealed with 'appEncrypt'. Needs a session.+appDecrypt :: Dongle -> ByteString -> IO ByteString+appDecrypt d@(Dongle h) packed = do+  a <- api+  useBytes packed $ \pp n -> readBytes (Just d) "licdf_app_decrypt" (fAppDecrypt a h pp n)
+ src/KeyNub/LicDongle/Library.hs view
@@ -0,0 +1,252 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE ForeignFunctionInterface #-}+{-# LANGUAGE ScopedTypeVariables #-}+-- | The native library: found, loaded once per process, and its functions+-- resolved by name into an 'Api' table. Applications use "KeyNub.LicDongle";+-- this module is exposed for the two path functions and the error type.+module KeyNub.LicDongle.Library+  ( Api(..)+  , api+  , setLibraryPath+  , libraryPath+  , loadedLibraryPath+  , LibraryError(..)+  ) where++import Control.Concurrent.MVar+import Control.Exception (Exception, SomeException, throwIO, try)+import Data.Word (Word8)+import Foreign.C.String (CString)+import Foreign.C.Types (CInt(..))+import Foreign.Ptr (FunPtr, Ptr, castFunPtr)+import System.Directory (doesFileExist, getCurrentDirectory)+import System.Environment (lookupEnv)+import System.FilePath ((</>), takeDirectory)+import System.IO.Unsafe (unsafePerformIO)+import qualified System.Info++#if defined(mingw32_HOST_OS)+import Foreign.Ptr (castPtrToFunPtr)+import System.Win32.DLL (getProcAddress, loadLibrary)+import System.Win32.Types (HMODULE)+#else+import System.Posix.DynamicLinker (DL, RTLDFlags(..), dlopen, dlsym)+#endif++-- | Loading problems: no library found, or a library without the expected+-- functions.+newtype LibraryError = LibraryError String+  deriving (Eq, Show)++instance Exception LibraryError++-- Every shape of function the flat API has.+type F_ppp     = Ptr CInt -> Ptr CInt -> Ptr CInt -> IO CInt+type F_p       = Ptr CInt -> IO CInt+type F_isi     = CInt -> CString -> CInt -> IO CInt+type F_s       = CString -> IO CInt+type F_i       = CInt -> IO CInt+type F_ibi     = CInt -> Ptr Word8 -> CInt -> IO CInt+type F_info    = CInt -> Ptr CInt -> Ptr CInt -> Ptr CInt -> Ptr CInt -> Ptr CInt+                 -> Ptr CInt -> Ptr CInt -> Ptr CInt -> IO CInt+type F_genuine = CInt -> Ptr CInt -> CString -> CInt -> CString -> CInt -> IO CInt+type F_ip      = CInt -> Ptr CInt -> IO CInt+type F_iip     = CInt -> CInt -> Ptr CInt -> IO CInt+type F_name    = CInt -> CInt -> CString -> CInt -> Ptr CInt -> IO CInt+type F_isp     = CInt -> CString -> Ptr CInt -> IO CInt+type F_read    = CInt -> CString -> Ptr Word8 -> CInt -> Ptr CInt -> IO CInt+type F_write   = CInt -> CString -> Ptr Word8 -> CInt -> IO CInt+type F_is      = CInt -> CString -> IO CInt+type F_enc     = CInt -> CInt -> Ptr Word8 -> CInt -> Ptr Word8 -> CInt -> Ptr CInt -> IO CInt+type F_dec     = CInt -> Ptr Word8 -> CInt -> Ptr Word8 -> CInt -> Ptr CInt -> IO CInt++foreign import ccall "dynamic" mk_ppp     :: FunPtr F_ppp -> F_ppp+foreign import ccall "dynamic" mk_p       :: FunPtr F_p -> F_p+foreign import ccall "dynamic" mk_isi     :: FunPtr F_isi -> F_isi+foreign import ccall "dynamic" mk_s       :: FunPtr F_s -> F_s+foreign import ccall "dynamic" mk_i       :: FunPtr F_i -> F_i+foreign import ccall "dynamic" mk_ibi     :: FunPtr F_ibi -> F_ibi+foreign import ccall "dynamic" mk_info    :: FunPtr F_info -> F_info+foreign import ccall "dynamic" mk_genuine :: FunPtr F_genuine -> F_genuine+foreign import ccall "dynamic" mk_ip      :: FunPtr F_ip -> F_ip+foreign import ccall "dynamic" mk_iip     :: FunPtr F_iip -> F_iip+foreign import ccall "dynamic" mk_name    :: FunPtr F_name -> F_name+foreign import ccall "dynamic" mk_isp     :: FunPtr F_isp -> F_isp+foreign import ccall "dynamic" mk_read    :: FunPtr F_read -> F_read+foreign import ccall "dynamic" mk_write   :: FunPtr F_write -> F_write+foreign import ccall "dynamic" mk_is      :: FunPtr F_is -> F_is+foreign import ccall "dynamic" mk_enc     :: FunPtr F_enc -> F_enc+foreign import ccall "dynamic" mk_dec     :: FunPtr F_dec -> F_dec++-- | The flat API's functions, resolved from the loaded library.+data Api = Api+  { fVersion         :: F_ppp+  , fDeviceCount     :: F_p+  , fDeviceSerial    :: F_isi+  , fDevicePath      :: F_isi+  , fOpen            :: F_s+  , fOpenPath        :: F_s+  , fClose           :: F_i+  , fSetTrustRoot    :: F_ibi+  , fGetSerial       :: F_isi+  , fGetInfo         :: F_info+  , fVerifyGenuine   :: F_genuine+  , fSessionOpen     :: F_i+  , fSessionClose    :: F_i+  , fWriteAuth       :: F_ibi+  , fWriteAuthRotate :: F_ibi+  , fRecordCount     :: F_ip+  , fRecordName      :: F_name+  , fRecordSize      :: F_isp+  , fRecordRead      :: F_read+  , fRecordWrite     :: F_write+  , fRecordErase     :: F_is+  , fRecordEraseAll  :: F_i+  , fCounterRead     :: F_iip+  , fCounterIncrement :: F_iip+  , fAppEncrypt      :: F_enc+  , fAppDecrypt      :: F_dec+  , fStrerror        :: F_isi+  , fLastError       :: F_isi+  }++data State = State+  { chosen :: Maybe FilePath          -- named with setLibraryPath, not yet loaded+  , loaded :: Maybe (FilePath, Api)+  }++{-# NOINLINE stateVar #-}+stateVar :: MVar State+stateVar = unsafePerformIO (newMVar (State Nothing Nothing))++-- | Names the native library to load. Call it before the first call; once a+-- library is loaded, naming a different one throws 'LibraryError'.+setLibraryPath :: FilePath -> IO ()+setLibraryPath p = modifyMVar_ stateVar $ \st -> case loaded st of+  Just (lp, _) | lp /= p ->+    throwIO (LibraryError ("the KeyNub library is already loaded from " ++ lp+                           ++ "; a process loads it once"))+  _ -> pure st { chosen = Just p }++-- | The library in use, or the one the next call would load.+libraryPath :: IO FilePath+libraryPath = do+  st <- readMVar stateVar+  case loaded st of+    Just (lp, _) -> pure lp+    Nothing -> resolve (chosen st)++-- | The library in use, once one is loaded.+loadedLibraryPath :: IO (Maybe FilePath)+loadedLibraryPath = fmap fst . loaded <$> readMVar stateVar++-- | The function table, loading the library on the first call.+api :: IO Api+api = modifyMVar stateVar $ \st -> case loaded st of+  Just (_, a) -> pure (st, a)+  Nothing -> do+    p <- resolve (chosen st)+    a <- load p+    pure (st { loaded = Just (p, a) }, a)++-- The path given to setLibraryPath, then KEYNUB_LICDONGLE_FLAT_LIBRARY, then+-- natives/<platform>/ from the working directory upwards (a clone of the SDK+-- repository), then the bare name for the system loader to find.+resolve :: Maybe FilePath -> IO FilePath+resolve (Just p) = pure p+resolve Nothing = do+  env <- lookupEnv "KEYNUB_LICDONGLE_FLAT_LIBRARY"+  case env of+    Just p | not (null p) -> pure p+    _ -> do+      found <- searchNatives+      pure (maybe basename id found)++basename :: FilePath+basename = case System.Info.os of+  "mingw32" -> "keynub_licdongle_flat.dll"+  "darwin"  -> "libkeynub_licdongle_flat.dylib"+  _         -> "libkeynub_licdongle_flat.so"++-- The natives/<platform> folder name of the SDK repository for this process.+platformFolder :: Maybe String+platformFolder = case (System.Info.os, System.Info.arch) of+  ("mingw32", "x86_64")  -> Just "win-x64"+  ("mingw32", "i386")    -> Just "win-x86"+  ("mingw32", "aarch64") -> Just "win-arm64"+  ("linux", "x86_64")    -> Just "linux-x64"+  ("linux", "aarch64")   -> Just "linux-arm64"+  ("darwin", "x86_64")   -> Just "osx-x64"+  ("darwin", "aarch64")  -> Just "osx-arm64"+  _                      -> Nothing++searchNatives :: IO (Maybe FilePath)+searchNatives = case platformFolder of+  Nothing -> pure Nothing+  Just rid -> getCurrentDirectory >>= go rid+  where+    go rid dir = do+      let candidate = dir </> "natives" </> rid </> basename+      present <- doesFileExist candidate+      if present+        then pure (Just candidate)+        else let parent = takeDirectory dir+             in if parent == dir then pure Nothing else go rid parent++load :: FilePath -> IO Api+load path = do+  sym <- opener path+  let get :: String -> IO (FunPtr a)+      get name = castFunPtr <$> sym name+  Api <$> (mk_ppp     <$> get "licdf_version")+      <*> (mk_p       <$> get "licdf_device_count")+      <*> (mk_isi     <$> get "licdf_device_serial")+      <*> (mk_isi     <$> get "licdf_device_path")+      <*> (mk_s       <$> get "licdf_open")+      <*> (mk_s       <$> get "licdf_open_path")+      <*> (mk_i       <$> get "licdf_close")+      <*> (mk_ibi     <$> get "licdf_set_trust_root")+      <*> (mk_isi     <$> get "licdf_get_serial")+      <*> (mk_info    <$> get "licdf_get_info")+      <*> (mk_genuine <$> get "licdf_verify_genuine")+      <*> (mk_i       <$> get "licdf_session_open")+      <*> (mk_i       <$> get "licdf_session_close")+      <*> (mk_ibi     <$> get "licdf_write_auth")+      <*> (mk_ibi     <$> get "licdf_write_auth_rotate")+      <*> (mk_ip      <$> get "licdf_record_count")+      <*> (mk_name    <$> get "licdf_record_name")+      <*> (mk_isp     <$> get "licdf_record_size")+      <*> (mk_read    <$> get "licdf_record_read")+      <*> (mk_write   <$> get "licdf_record_write")+      <*> (mk_is      <$> get "licdf_record_erase")+      <*> (mk_i       <$> get "licdf_record_erase_all")+      <*> (mk_iip     <$> get "licdf_counter_read")+      <*> (mk_iip     <$> get "licdf_counter_increment")+      <*> (mk_enc     <$> get "licdf_app_encrypt")+      <*> (mk_dec     <$> get "licdf_app_decrypt")+      <*> (mk_isi     <$> get "licdf_strerror")+      <*> (mk_isi     <$> get "licdf_last_error")++-- Opens the library and gives a symbol lookup over it.+opener :: FilePath -> IO (String -> IO (FunPtr ()))+#if defined(mingw32_HOST_OS)+opener path = do+  r <- try (loadLibrary path) :: IO (Either SomeException HMODULE)+  handle <- either (cannotLoad path) pure r+  pure $ \name -> do+    r2 <- try (getProcAddress handle name) :: IO (Either SomeException (Ptr ()))+    either (missing path name) (pure . castPtrToFunPtr) r2+#else+opener path = do+  r <- try (dlopen path [RTLD_NOW]) :: IO (Either SomeException DL)+  dl <- either (cannotLoad path) pure r+  pure $ \name -> do+    r2 <- try (dlsym dl name) :: IO (Either SomeException (FunPtr ()))+    either (missing path name) pure r2+#endif++cannotLoad :: FilePath -> SomeException -> IO a+cannotLoad path e = throwIO (LibraryError ("cannot load the KeyNub library " ++ path ++ ": " ++ show e))++missing :: FilePath -> String -> SomeException -> IO a+missing path name _ = throwIO (LibraryError (path ++ " does not export " ++ name))
+ test/StubTest.hs view
@@ -0,0 +1,183 @@+{-# LANGUAGE ScopedTypeVariables #-}+-- Every call of the binding against a stand-in for the flat C API: the SDK's+-- flat layer compiled together with the C ABI stand-in+-- (bindings/julia/test/stub/licd_stub.c, one imaginary dongle held in memory)+-- into one shared library, with a C compiler from the path (cc, gcc, clang or+-- zig cc). KEYNUB_LICDONGLE_FLAT_LIBRARY naming an already compiled stand-in+-- skips the build. Exit code 0 when every check passed.+module Main (main) where++import Control.Exception (SomeException, try)+import Control.Monad (forM_, unless)+import Data.Bits (xor)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BC+import Data.IORef+import Data.List (sort)+import System.Directory (createDirectoryIfMissing, doesFileExist, getCurrentDirectory, getTemporaryDirectory)+import System.Environment (lookupEnv)+import System.Exit (exitFailure, exitSuccess)+import System.FilePath ((</>), takeDirectory)+import qualified System.Info+import System.Process (readProcessWithExitCode)+import System.Exit (ExitCode(..))++import KeyNub.LicDongle++main :: IO ()+main = do+  lib <- standIn+  setLibraryPath lib+  failures <- newIORef (0 :: Int)+  let check cond what = unless cond $ do+        modifyIORef' failures (+ 1)+        putStrLn ("  FAIL  " ++ what)+      fails st what act = do+        r <- try act+        case r of+          Left (e :: LicDongleError) -> check (errorStatus e == st) (what ++ ": " ++ show (errorStatus e))+          Right _ -> check False (what ++ ": no failure")+      serialValue = "04A1B2C3D4E5F6"+      factoryKey = BS.pack [0x30, 0x10, 0x01, 0x02, 0x03]+      replacementKey = BS.pack [0x30, 0x11, 0x09, 0x08, 0x07, 0x06]++  v <- libraryVersion+  check (v == (9, 8, 7)) "the stand-in reports 9.8.7"+  t <- statusText (-2)+  check (t == "no device") "strerror text"++  ds <- devices+  check (ds == [Device serialValue "stub:0"]) "enumeration"+  fails NoDevice "open with a wrong serial" (openSerial "nope")+  fails NoDevice "open at a wrong path" (openPath "stub:9")++  d <- open+  s <- serial d+  check (s == serialValue) "serial"+  i <- info d+  check (protocolVersion i == (1, 0) && firmwareVersion i == (2, 3, 4)) "versions"+  check (secureElementReady i && provisioned i && isolated i) "flags set"+  check (not (watchdogReboot i) && not (writeAuthRotated i)) "flags clear"+  check (dataCapacity i == 1024 * 1024 && dataFree i == 1000000) "storage"+  g <- verifyGenuine d+  check (genuineSerial g == serialValue && provisionedDate g == "2026-08-15") "genuine"+  isGenuine d >>= \ok -> check ok "isGenuine"++  fails CertInvalid "a non-DER root is refused" (setTrustRoot d (BS.pack [0x02, 0x01, 0x00]))+  setTrustRoot d (BS.pack ([0x30, 0x82, 0x01, 0x00] ++ replicate 128 0xAB))+  fails CertInvalid "verification under a foreign root" (verifyGenuine d)+  isGenuine d >>= \ok -> check (not ok) "isGenuine fails closed"+  setTrustRoot d (BS.pack ([0x30, 0x82, 0x01, 0x00] ++ replicate 128 0x01))+  isGenuine d >>= \ok -> check ok "genuine again under the issuing root"++  fails SessionExpired "records need a session" (records d)+  sessionOpen d+  let payload = BC.pack "license-blob-0123456789"+  fails AuthRequired "writing needs the write role" (writeRecord d "lic" payload)+  fails NotGenuine "a wrong key does not elevate" (authorizeWrite d (BS.pack [0x30, 0x00]))+  authorizeWrite d factoryKey+  writeRecord d "lic" payload+  readRecord d "lic" >>= \r -> check (r == payload) "read back what was written"+  writeRecord d "cfg" (BC.pack "cfgdata")+  recs <- records d+  check (sort (map recordName recs) == ["cfg", "lic"]) "record names"+  check ([recordSize r | r <- recs, recordName r == "lic"] == [BS.length payload]) "record size"+  readRecord d "cfg" >>= \r -> check (BC.unpack r == "cfgdata") "a string is stored as its bytes"+  fails NotFound "reading a missing record" (readRecord d "nope")+  fails InvalidArg "an empty name never erases" (eraseRecord d "")+  records d >>= \rs -> check (length rs == 2) "nothing erased by mistake"+  eraseRecord d "cfg"+  records d >>= \rs -> check (map recordName rs == ["lic"]) "one record after the erase"+  writeRecord d "empty" BS.empty+  readRecord d "empty" >>= \r -> check (r == BS.empty) "an empty record reads back empty"++  before <- readCounter d 0+  incrementCounter d 0 >>= \n -> check (n == before + 1) "increment returns the new value"+  c0 <- readCounter d 0+  c1 <- readCounter d 1+  check (c0 == before + 1 && c1 == 0) "counters"+  fails Range "a counter the dongle lacks" (readCounter d 7)++  let secret = BS.pack [fromIntegral ((3 * k + 7) `mod` 256) | k <- [0 .. 99 :: Int]]+  forM_ [(DeviceScope, 0), (DeveloperScope, 1)] $ \(scope, byte) -> do+    blob <- appEncrypt d scope secret+    check (BS.length blob > BS.length secret) ("sealed data is longer, " ++ show scope)+    check (BS.head blob == byte) ("scope byte " ++ show scope)+    appDecrypt d blob >>= \r -> check (r == secret) ("round trip " ++ show scope)+    let tampered = BS.init blob `BS.snoc` (BS.last blob `xor` 1)+    fails TagMismatch ("tampered data " ++ show scope) (appDecrypt d tampered)+  eraseAllRecords d+  records d >>= \rs -> check (null rs) "erase all leaves nothing"++  rotateWriteKey d replacementKey+  writeRecord d "lic" (BC.pack "still-writable")+  sessionClose d+  info d >>= \i2 -> check (writeAuthRotated i2) "the rotation flag is set"+  sessionOpen d+  fails NotGenuine "the factory key no longer elevates" (authorizeWrite d factoryKey)+  authorizeWrite d replacementKey+  writeRecord d "lic" (BC.pack "new-key-writes")+  readRecord d "lic" >>= \r -> check (BC.unpack r == "new-key-writes") "the new key writes"+  sessionClose d+  close d+  r <- try (serial d)+  check (either (\(_ :: LicDongleError) -> True) (const False) r) "a closed handle refuses calls"++  n <- readIORef failures+  if n == 0+    then putStrLn "keynub-licdongle: every call passed against the ABI stand-in" >> exitSuccess+    else putStrLn (show n ++ " check(s) failed") >> exitFailure++-- The compiled stand-in: from the environment, or built from the SDK sources+-- found by walking up from the working directory.+standIn :: IO FilePath+standIn = do+  env <- lookupEnv "KEYNUB_LICDONGLE_FLAT_LIBRARY"+  case env of+    Just p | not (null p) -> pure p+    _ -> do+      root <- sdkRoot+      tmp <- getTemporaryDirectory+      let dir = tmp </> "keynub-haskell-stub"+          out = dir </> libName+      createDirectoryIfMissing True dir+      let args = ["-shared", "-O1", "-DLICD_BUILD_SHARED", "-DLICDF_BUILD_SHARED",+                  "-I" ++ (root </> "core" </> "include"), "-I" ++ (root </> "bindings" </> "flat"),+                  "-o", out,+                  root </> "bindings" </> "flat" </> "licd_flat.c",+                  root </> "bindings" </> "julia" </> "test" </> "stub" </> "licd_stub.c"]+                 ++ (if System.Info.os == "mingw32" then [] else ["-fPIC"])+      ok <- tryCompilers [("cc", []), ("gcc", []), ("clang", []), ("zig", ["cc"])] args+      unless ok $ do+        putStrLn "the C ABI stand-in could not be compiled: no C compiler (cc, gcc, clang, zig cc) on the path"+        exitFailure+      pure out+  where+    libName = case System.Info.os of+      "mingw32" -> "keynub_licdongle_flat.dll"+      "darwin"  -> "libkeynub_licdongle_flat.dylib"+      _         -> "libkeynub_licdongle_flat.so"+    tryCompilers [] _ = pure False+    tryCompilers ((exe, pre) : rest) args = do+      r <- try (readProcessWithExitCode exe (pre ++ args) "") :: IO (Either SomeException (ExitCode, String, String))+      case r of+        Right (ExitSuccess, _, _) -> pure True+        Right (_, _, err) -> putStrLn (exe ++ ": " ++ err) >> tryCompilers rest args+        Left _ -> tryCompilers rest args++-- The SDK root: KEYNUB_SDK_ROOT, or the first directory upwards holding+-- bindings/flat/licd_flat.c.+sdkRoot :: IO FilePath+sdkRoot = do+  env <- lookupEnv "KEYNUB_SDK_ROOT"+  case env of+    Just p | not (null p) -> pure p+    _ -> getCurrentDirectory >>= go+  where+    go dir = do+      present <- doesFileExist (dir </> "bindings" </> "flat" </> "licd_flat.c")+      if present then pure dir else do+        let parent = takeDirectory dir+        if parent == dir+          then putStrLn "the SDK sources were not found above the working directory; set KEYNUB_SDK_ROOT" >> exitFailure+          else go parent