@hellolynk-ai/codex-plugin 0.0.0-stage → 0.2.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/LICENSE +92 -0
  3. package/README.md +101 -2
  4. package/bin/hellolynk-client-setup.mjs +2748 -0
  5. package/bin/hellolynk-mcp-config-shape.mjs +23 -0
  6. package/hellolynk-client-pack-manifest.json +619 -0
  7. package/hellolynk-client-pack-pin.json +4 -0
  8. package/package.json +58 -4
  9. package/plugins/hellolynk-codex/.app.json +3 -0
  10. package/plugins/hellolynk-codex/.codex-plugin/plugin.json +38 -0
  11. package/plugins/hellolynk-codex/.mcp.json +19 -0
  12. package/plugins/hellolynk-codex/assets/hellolynk-mark.png +0 -0
  13. package/plugins/hellolynk-codex/package.json +11 -0
  14. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-account.mjs +180 -0
  15. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-core.mjs +770 -0
  16. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-client-profile.mjs +191 -0
  17. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-auth.mjs +347 -0
  18. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-host-protected-store.mjs +2721 -0
  19. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-local-process-lock.mjs +157 -0
  20. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  21. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-platform-credential-store.mjs +773 -0
  22. package/plugins/hellolynk-codex/runtime/bootstrap/core/hellolynk-runtime-safety.cjs +471 -0
  23. package/plugins/hellolynk-codex/runtime/bootstrap/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  24. package/plugins/hellolynk-codex/runtime/connect-activate.mjs +837 -0
  25. package/plugins/hellolynk-codex/runtime/core/hellolynk-agent-configurator.mjs +701 -0
  26. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-account.mjs +180 -0
  27. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-core.mjs +770 -0
  28. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-profile.mjs +191 -0
  29. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-entry.mjs +31 -0
  30. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update-runtime.mjs +600 -0
  31. package/plugins/hellolynk-codex/runtime/core/hellolynk-client-update.mjs +453 -0
  32. package/plugins/hellolynk-codex/runtime/core/hellolynk-contact-request-e2ee.mjs +867 -0
  33. package/plugins/hellolynk-codex/runtime/core/hellolynk-device-enrollment.mjs +435 -0
  34. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee-client.mjs +1964 -0
  35. package/plugins/hellolynk-codex/runtime/core/hellolynk-direct-text-e2ee.mjs +1207 -0
  36. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-auth.mjs +347 -0
  37. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-protected-store.mjs +2721 -0
  38. package/plugins/hellolynk-codex/runtime/core/hellolynk-host-transport-client.mjs +946 -0
  39. package/plugins/hellolynk-codex/runtime/core/hellolynk-host.mjs +2374 -0
  40. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-confirmation-ledger.mjs +256 -0
  41. package/plugins/hellolynk-codex/runtime/core/hellolynk-human-relationship-controller.mjs +1234 -0
  42. package/plugins/hellolynk-codex/runtime/core/hellolynk-inbox-read-state.mjs +11 -0
  43. package/plugins/hellolynk-codex/runtime/core/hellolynk-indexed-message-store.mjs +1558 -0
  44. package/plugins/hellolynk-codex/runtime/core/hellolynk-instance-conversation.mjs +185 -0
  45. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contact-requests.mjs +1733 -0
  46. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-contacts.mjs +1228 -0
  47. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-migration.mjs +766 -0
  48. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-data-protection.mjs +747 -0
  49. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-identity-trust.mjs +274 -0
  50. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-messaging.mjs +3043 -0
  51. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-peer-transport.mjs +103 -0
  52. package/plugins/hellolynk-codex/runtime/core/hellolynk-local-process-lock.mjs +157 -0
  53. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-inbox.mjs +230 -0
  54. package/plugins/hellolynk-codex/runtime/core/hellolynk-model-notices.cjs +60 -0
  55. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-addressing-tools.cjs +231 -0
  56. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-conversations.mjs +280 -0
  57. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-human-confirmation-broker.mjs +1036 -0
  58. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-inbox-runtime.mjs +553 -0
  59. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-notice.mjs +50 -0
  60. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-reply-routes.mjs +96 -0
  61. package/plugins/hellolynk-codex/runtime/core/hellolynk-native-worker-lease.mjs +84 -0
  62. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-assembly.mjs +955 -0
  63. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-installation-store.mjs +765 -0
  64. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-launcher.mjs +665 -0
  65. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-process.mjs +4374 -0
  66. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-store-adapters.mjs +548 -0
  67. package/plugins/hellolynk-codex/runtime/core/hellolynk-neutral-host-workbench-read-model.mjs +1365 -0
  68. package/plugins/hellolynk-codex/runtime/core/hellolynk-platform-credential-store.mjs +773 -0
  69. package/plugins/hellolynk-codex/runtime/core/hellolynk-relay-client-cli.mjs +232 -0
  70. package/plugins/hellolynk-codex/runtime/core/hellolynk-release-retention.mjs +277 -0
  71. package/plugins/hellolynk-codex/runtime/core/hellolynk-runtime-safety.cjs +471 -0
  72. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-action-grants.mjs +457 -0
  73. package/plugins/hellolynk-codex/runtime/core/hellolynk-workbench-connection.mjs +234 -0
  74. package/plugins/hellolynk-codex/runtime/neutral-host-bootstrap.mjs +64 -0
  75. package/plugins/hellolynk-codex/runtime/neutral-host-release-entry.mjs +70 -0
  76. package/plugins/hellolynk-codex/runtime/neutral-host-release-loader.mjs +156 -0
  77. package/plugins/hellolynk-codex/runtime/neutral-host-release-manifest.json +358 -0
  78. package/plugins/hellolynk-codex/runtime/neutral-host-release-pin.json +4 -0
  79. package/plugins/hellolynk-codex/runtime/platform/windows/configure-agent.ps1 +281 -0
  80. package/plugins/hellolynk-codex/runtime/platform/windows/credential-store.ps1 +844 -0
  81. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-host-protected-store.ps1 +2866 -0
  82. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-human-confirmation-ledger.ps1 +155 -0
  83. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-local-data-key.ps1 +300 -0
  84. package/plugins/hellolynk-codex/runtime/platform/windows/hellolynk-native-human-confirmation-broker.ps1 +216 -0
  85. package/plugins/hellolynk-codex/runtime/workbench/candidate-bootstrap.mjs +54 -0
  86. package/plugins/hellolynk-codex/runtime/workbench/contract.mjs +1314 -0
  87. package/plugins/hellolynk-codex/runtime/workbench/hellolynk-workbench-build.json +215 -0
  88. package/plugins/hellolynk-codex/runtime/workbench/host-capabilities.mjs +80 -0
  89. package/plugins/hellolynk-codex/runtime/workbench/i18n.mjs +1127 -0
  90. package/plugins/hellolynk-codex/runtime/workbench/loopback-surface.mjs +740 -0
  91. package/plugins/hellolynk-codex/runtime/workbench/mcp-app-bundle.mjs +97 -0
  92. package/plugins/hellolynk-codex/runtime/workbench/service.mjs +485 -0
  93. package/plugins/hellolynk-codex/runtime/workbench/state.mjs +421 -0
  94. package/plugins/hellolynk-codex/runtime/workbench/transports.mjs +171 -0
  95. package/plugins/hellolynk-codex/runtime/workbench/ui/app.mjs +6120 -0
  96. package/plugins/hellolynk-codex/runtime/workbench/ui/connection-form.mjs +155 -0
  97. package/plugins/hellolynk-codex/runtime/workbench/ui/hellolynk-mark.png +0 -0
  98. package/plugins/hellolynk-codex/runtime/workbench/ui/http-transport.mjs +247 -0
  99. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/arrow-left.svg +3 -0
  100. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/close.svg +3 -0
  101. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/contacts.svg +3 -0
  102. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/inbox.svg +3 -0
  103. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/refresh.svg +3 -0
  104. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/search.svg +3 -0
  105. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/security.svg +3 -0
  106. package/plugins/hellolynk-codex/runtime/workbench/ui/icons/settings.svg +3 -0
  107. package/plugins/hellolynk-codex/runtime/workbench/ui/index.html +139 -0
  108. package/plugins/hellolynk-codex/runtime/workbench/ui/loopback-entry.mjs +26 -0
  109. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-client.mjs +151 -0
  110. package/plugins/hellolynk-codex/runtime/workbench/ui/mcp-app-entry.mjs +452 -0
  111. package/plugins/hellolynk-codex/runtime/workbench/ui/styles.css +686 -0
  112. package/plugins/hellolynk-codex/server/codex-neutral-production.mjs +520 -0
  113. package/plugins/hellolynk-codex/server/mcp-proxy.mjs +306 -0
  114. package/plugins/hellolynk-codex/server/mcp-server.mjs +1193 -0
  115. package/plugins/hellolynk-codex/server/native-conversation-directory.mjs +33 -0
  116. package/plugins/hellolynk-codex/server/native-reply-routes.mjs +32 -0
  117. package/plugins/hellolynk-codex/server/native-task-client.mjs +382 -0
  118. package/plugins/hellolynk-codex/server/native-task-runtime.mjs +8 -0
  119. package/plugins/hellolynk-codex/server/neutral-host-adapter.mjs +649 -0
  120. package/plugins/hellolynk-codex/server/workbench-runtime.mjs +180 -0
  121. package/plugins/hellolynk-codex/skills/hellolynk-open/SKILL.md +11 -0
  122. package/plugins/hellolynk-codex/skills/hellolynk-open/agents/openai.yaml +4 -0
  123. package/tools/hellolynk-activate-neutral-host-clients.mjs +837 -0
@@ -0,0 +1,844 @@
1
+ param(
2
+ [Parameter(Mandatory = $true)]
3
+ [ValidateSet(
4
+ "PrepareRoot",
5
+ "SecureReleaseTree",
6
+ "VerifyReleaseTree",
7
+ "StoreProfile",
8
+ "StoreStatus",
9
+ "ProbeCredential",
10
+ "ValidateManagedFile",
11
+ "SecureManagedFile",
12
+ "Store",
13
+ "Load",
14
+ "Remove",
15
+ "StoreRuntimeSession",
16
+ "LoadRuntimeSession",
17
+ "RemoveRuntimeSession",
18
+ "StoreE2eePrivateKeys",
19
+ "LoadE2eePrivateKeys",
20
+ "LoadE2eePrivateKeysBatch",
21
+ "RemoveE2eePrivateKeys"
22
+ )]
23
+ [string]$Command,
24
+
25
+ [Parameter(Mandatory = $true)]
26
+ [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$')]
27
+ [string]$ProfileId,
28
+
29
+ [string]$ClientRoot = "",
30
+
31
+ [string]$ManagedRelativePath = "",
32
+
33
+ [string]$StoreRoot = "",
34
+
35
+ [string]$E2eeStoreRoot = ""
36
+ )
37
+
38
+ $ErrorActionPreference = "Stop"
39
+ $ProgressPreference = "SilentlyContinue"
40
+
41
+ Add-Type -TypeDefinition @'
42
+ using System;
43
+ using System.ComponentModel;
44
+ using System.Runtime.InteropServices;
45
+ using Microsoft.Win32.SafeHandles;
46
+
47
+ public static class HelloLynkWindowsFileNative {
48
+ public const uint GENERIC_READ = 0x80000000;
49
+ public const uint FILE_SHARE_READ = 0x00000001;
50
+ public const uint FILE_SHARE_WRITE = 0x00000002;
51
+ public const uint FILE_SHARE_DELETE = 0x00000004;
52
+ public const uint OPEN_EXISTING = 3;
53
+ public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
54
+ public const uint FILE_FLAG_OPEN_REPARSE_POINT = 0x00200000;
55
+ public const uint MOVEFILE_REPLACE_EXISTING = 0x00000001;
56
+ public const uint MOVEFILE_WRITE_THROUGH = 0x00000008;
57
+
58
+ [StructLayout(LayoutKind.Sequential)]
59
+ public struct BY_HANDLE_FILE_INFORMATION {
60
+ public uint FileAttributes;
61
+ public System.Runtime.InteropServices.ComTypes.FILETIME CreationTime;
62
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastAccessTime;
63
+ public System.Runtime.InteropServices.ComTypes.FILETIME LastWriteTime;
64
+ public uint VolumeSerialNumber;
65
+ public uint FileSizeHigh;
66
+ public uint FileSizeLow;
67
+ public uint NumberOfLinks;
68
+ public uint FileIndexHigh;
69
+ public uint FileIndexLow;
70
+ }
71
+
72
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
73
+ public static extern SafeFileHandle CreateFileW(
74
+ string fileName, uint desiredAccess, uint shareMode, IntPtr securityAttributes,
75
+ uint creationDisposition, uint flagsAndAttributes, IntPtr templateFile);
76
+
77
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
78
+ public static extern uint GetFinalPathNameByHandleW(
79
+ SafeFileHandle file, System.Text.StringBuilder path, uint length, uint flags);
80
+
81
+ [DllImport("kernel32.dll", SetLastError = true)]
82
+ public static extern bool GetFileInformationByHandle(
83
+ SafeFileHandle file, out BY_HANDLE_FILE_INFORMATION information);
84
+
85
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
86
+ public static extern bool MoveFileExW(string existingName, string newName, uint flags);
87
+
88
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
89
+ public static extern bool CreateDirectoryW(string path, IntPtr securityAttributes);
90
+ }
91
+ '@
92
+
93
+ $script:CurrentSid = [Security.Principal.WindowsIdentity]::GetCurrent().User
94
+ $script:SystemSid = [Security.Principal.SecurityIdentifier]::new("S-1-5-18")
95
+ $script:AdministratorsSid = [Security.Principal.SecurityIdentifier]::new("S-1-5-32-544")
96
+ $script:TrustedInstallerSid = try {
97
+ ([Security.Principal.NTAccount]::new("NT SERVICE", "TrustedInstaller")).Translate(
98
+ [Security.Principal.SecurityIdentifier]
99
+ )
100
+ } catch { $null }
101
+ $script:AllowedSids = @(
102
+ $script:CurrentSid.Value,
103
+ $script:SystemSid.Value,
104
+ $script:AdministratorsSid.Value
105
+ )
106
+ $script:TrustedAncestorOwnerSids = @($script:AllowedSids)
107
+ if ($null -ne $script:TrustedInstallerSid) {
108
+ $script:TrustedAncestorOwnerSids += $script:TrustedInstallerSid.Value
109
+ }
110
+
111
+ function Fail([string]$Code) {
112
+ [Console]::Error.WriteLine($Code)
113
+ exit 1
114
+ }
115
+
116
+ function Normalize-HelloLynkAbsoluteFixedPath([string]$PathValue) {
117
+ if (
118
+ [string]::IsNullOrWhiteSpace($PathValue) -or
119
+ -not [IO.Path]::IsPathFullyQualified($PathValue) -or
120
+ $PathValue.StartsWith("\\", [StringComparison]::Ordinal) -or
121
+ $PathValue.StartsWith("//", [StringComparison]::Ordinal)
122
+ ) { Fail "client_root_path_invalid" }
123
+ try { $full = [IO.Path]::GetFullPath($PathValue) } catch { Fail "client_root_path_invalid" }
124
+ $trimmedRequested = $PathValue.TrimEnd([char[]]@('\', '/'))
125
+ $trimmedFull = $full.TrimEnd([char[]]@('\', '/'))
126
+ if (-not $trimmedRequested.Equals($trimmedFull, [StringComparison]::OrdinalIgnoreCase)) {
127
+ Fail "client_root_alias_rejected"
128
+ }
129
+ try { $drive = [IO.DriveInfo]::new([IO.Path]::GetPathRoot($full)) } catch { Fail "client_root_volume_invalid" }
130
+ if ($drive.DriveType -ne [IO.DriveType]::Fixed) { Fail "client_root_fixed_local_required" }
131
+ return $full
132
+ }
133
+
134
+ function Assert-HelloLynkNoReparseAncestors([string]$PathValue) {
135
+ $current = $PathValue
136
+ while (-not [string]::IsNullOrWhiteSpace($current)) {
137
+ if ([IO.Directory]::Exists($current) -or [IO.File]::Exists($current)) {
138
+ try { $item = Get-Item -LiteralPath $current -Force } catch { Fail "client_root_path_untrusted" }
139
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
140
+ Fail "client_root_reparse_rejected"
141
+ }
142
+ }
143
+ $parent = [IO.Path]::GetDirectoryName($current.TrimEnd([char[]]@('\', '/')))
144
+ if ([string]::IsNullOrWhiteSpace($parent) -or $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) { break }
145
+ $current = $parent
146
+ }
147
+ }
148
+
149
+ function Get-HelloLynkExistingAncestorDirectories([string]$RootPath) {
150
+ $directories = [Collections.Generic.List[string]]::new()
151
+ $current = [IO.Path]::GetDirectoryName($RootPath.TrimEnd([char[]]@('\', '/')))
152
+ while (-not [string]::IsNullOrWhiteSpace($current)) {
153
+ if ([IO.Directory]::Exists($current)) { $directories.Add($current) }
154
+ $parent = [IO.Path]::GetDirectoryName($current.TrimEnd([char[]]@('\', '/')))
155
+ if ([string]::IsNullOrWhiteSpace($parent) -or $parent.Equals($current, [StringComparison]::OrdinalIgnoreCase)) {
156
+ break
157
+ }
158
+ $current = $parent
159
+ }
160
+ $ordered = @($directories)
161
+ [Array]::Reverse($ordered)
162
+ return $ordered
163
+ }
164
+
165
+ function Assert-HelloLynkTrustedAncestorAcl([string]$RootPath) {
166
+ $rootParent = [IO.Path]::GetDirectoryName($RootPath.TrimEnd([char[]]@('\', '/')))
167
+ $directories = @(Get-HelloLynkExistingAncestorDirectories $RootPath)
168
+ $dangerousRights = (
169
+ [Security.AccessControl.FileSystemRights]::CreateFiles -bor
170
+ [Security.AccessControl.FileSystemRights]::CreateDirectories -bor
171
+ [Security.AccessControl.FileSystemRights]::WriteAttributes -bor
172
+ [Security.AccessControl.FileSystemRights]::WriteExtendedAttributes -bor
173
+ [Security.AccessControl.FileSystemRights]::Delete -bor
174
+ [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles -bor
175
+ [Security.AccessControl.FileSystemRights]::ChangePermissions -bor
176
+ [Security.AccessControl.FileSystemRights]::TakeOwnership
177
+ )
178
+ foreach ($directory in $directories) {
179
+ Assert-HelloLynkNoReparseAncestors $directory
180
+ $identity = Open-HelloLynkIdentity $directory $true
181
+ $identity.Handle.Dispose()
182
+ try { $acl = Get-Acl -LiteralPath $directory } catch { Fail "client_root_ancestor_acl_untrusted" }
183
+ try {
184
+ $ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate(
185
+ [Security.Principal.SecurityIdentifier]
186
+ ).Value
187
+ } catch {
188
+ try { $ownerSid = ([Security.Principal.SecurityIdentifier]$acl.Owner).Value } catch {
189
+ Fail "client_root_ancestor_owner_untrusted"
190
+ }
191
+ }
192
+ if ($script:TrustedAncestorOwnerSids -notcontains $ownerSid) {
193
+ Fail "client_root_ancestor_owner_untrusted"
194
+ }
195
+
196
+ # Package/capability grants constrain an AppContainer in addition to its
197
+ # user's rights; they are not grants to another Windows user. Only accept
198
+ # specific app identities on this user's own profile ancestors. The actual
199
+ # credential root still requires the closed DACL checked below.
200
+ $userProfile = [Environment]::GetFolderPath([Environment+SpecialFolder]::UserProfile).TrimEnd('\', '/')
201
+ $isOwnProfileAncestor = $ownerSid -eq $script:CurrentSid.Value -and (
202
+ $directory.Equals($userProfile, [StringComparison]::OrdinalIgnoreCase) -or
203
+ $directory.StartsWith($userProfile + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)
204
+ )
205
+ $isRootParent = $directory.Equals($rootParent, [StringComparison]::OrdinalIgnoreCase)
206
+ $directoryIndex = [Array]::IndexOf($directories, $directory)
207
+ $nextDirectoryProtected = $false
208
+ if ($directoryIndex -ge 0 -and $directoryIndex + 1 -lt $directories.Count) {
209
+ try {
210
+ $nextDirectoryProtected = (Get-Acl -LiteralPath $directories[$directoryIndex + 1]).AreAccessRulesProtected
211
+ } catch { Fail "client_root_ancestor_acl_untrusted" }
212
+ }
213
+ $rules = $acl.GetAccessRules($true, $true, [Security.Principal.SecurityIdentifier])
214
+ foreach ($rule in $rules) {
215
+ if (
216
+ $rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow -or
217
+ $script:TrustedAncestorOwnerSids -contains $rule.IdentityReference.Value -or
218
+ ($isOwnProfileAncestor -and $rule.IdentityReference.Value -match '^S-1-15-[23]-(?:\d+-){6,7}\d+$') -or
219
+ (($rule.PropagationFlags -band [Security.AccessControl.PropagationFlags]::InheritOnly) -ne 0)
220
+ ) { continue }
221
+ $rights = $rule.FileSystemRights
222
+ $hasDangerousRights = (($rights -band $dangerousRights) -ne 0)
223
+ $hasDeleteChild = (
224
+ ($rights -band [Security.AccessControl.FileSystemRights]::DeleteSubdirectoriesAndFiles) -ne 0
225
+ )
226
+ if (
227
+ $hasDeleteChild -or
228
+ ($hasDangerousRights -and ($isRootParent -or -not $nextDirectoryProtected))
229
+ ) { Fail "client_root_ancestor_dacl_untrusted" }
230
+ }
231
+ }
232
+ }
233
+
234
+ function New-HelloLynkAcl([bool]$Directory) {
235
+ if ($Directory) {
236
+ $acl = [Security.AccessControl.DirectorySecurity]::new()
237
+ $inheritance = [Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [Security.AccessControl.InheritanceFlags]::ObjectInherit
238
+ } else {
239
+ $acl = [Security.AccessControl.FileSecurity]::new()
240
+ $inheritance = [Security.AccessControl.InheritanceFlags]::None
241
+ }
242
+ $acl.SetOwner($script:CurrentSid)
243
+ $acl.SetAccessRuleProtection($true, $false)
244
+ foreach ($sid in @($script:CurrentSid, $script:SystemSid, $script:AdministratorsSid)) {
245
+ $rule = if ($Directory) {
246
+ [Security.AccessControl.FileSystemAccessRule]::new(
247
+ $sid,
248
+ [Security.AccessControl.FileSystemRights]::FullControl,
249
+ $inheritance,
250
+ [Security.AccessControl.PropagationFlags]::None,
251
+ [Security.AccessControl.AccessControlType]::Allow
252
+ )
253
+ } else {
254
+ [Security.AccessControl.FileSystemAccessRule]::new(
255
+ $sid,
256
+ [Security.AccessControl.FileSystemRights]::FullControl,
257
+ [Security.AccessControl.AccessControlType]::Allow
258
+ )
259
+ }
260
+ $null = $acl.AddAccessRule($rule)
261
+ }
262
+ return $acl
263
+ }
264
+
265
+ function Set-HelloLynkSecureAcl([string]$PathValue, [bool]$Directory) {
266
+ try { Set-Acl -LiteralPath $PathValue -AclObject (New-HelloLynkAcl $Directory) } catch { Fail "client_root_acl_apply_failed" }
267
+ }
268
+
269
+ function Assert-HelloLynkSecureAcl([string]$PathValue, [bool]$Directory) {
270
+ try { $acl = Get-Acl -LiteralPath $PathValue } catch { Fail "client_root_acl_untrusted" }
271
+ try { $ownerSid = ([Security.Principal.NTAccount]$acl.Owner).Translate([Security.Principal.SecurityIdentifier]).Value } catch {
272
+ try { $ownerSid = ([Security.Principal.SecurityIdentifier]$acl.Owner).Value } catch { Fail "client_root_owner_untrusted" }
273
+ }
274
+ if ($ownerSid -ne $script:CurrentSid.Value) { Fail "client_root_owner_untrusted" }
275
+ if (-not $acl.AreAccessRulesProtected) { Fail "client_root_dacl_unprotected" }
276
+ $rules = $acl.GetAccessRules($true, $false, [Security.Principal.SecurityIdentifier])
277
+ $currentFullControl = $false
278
+ $systemFullControl = $false
279
+ $administratorsFullControl = $false
280
+ foreach ($rule in $rules) {
281
+ $sid = $rule.IdentityReference.Value
282
+ if ($script:AllowedSids -notcontains $sid) { Fail "client_root_dacl_untrusted" }
283
+ if ($rule.AccessControlType -ne [Security.AccessControl.AccessControlType]::Allow) {
284
+ Fail "client_root_dacl_untrusted"
285
+ }
286
+ $hasFullControl = (
287
+ ($rule.FileSystemRights -band [Security.AccessControl.FileSystemRights]::FullControl) -eq
288
+ [Security.AccessControl.FileSystemRights]::FullControl
289
+ )
290
+ if ($hasFullControl) {
291
+ if ($sid -eq $script:CurrentSid.Value) { $currentFullControl = $true }
292
+ if ($sid -eq $script:SystemSid.Value) { $systemFullControl = $true }
293
+ if ($sid -eq $script:AdministratorsSid.Value) { $administratorsFullControl = $true }
294
+ }
295
+ }
296
+ if (-not ($currentFullControl -and $systemFullControl -and $administratorsFullControl)) {
297
+ Fail "client_root_dacl_untrusted"
298
+ }
299
+ if ($Directory -and -not [IO.Directory]::Exists($PathValue)) { Fail "client_root_not_directory" }
300
+ if (-not $Directory -and -not [IO.File]::Exists($PathValue)) { Fail "client_file_not_regular" }
301
+ }
302
+
303
+ function Open-HelloLynkIdentity([string]$PathValue, [bool]$Directory) {
304
+ $flags = [HelloLynkWindowsFileNative]::FILE_FLAG_OPEN_REPARSE_POINT
305
+ if ($Directory) { $flags = $flags -bor [HelloLynkWindowsFileNative]::FILE_FLAG_BACKUP_SEMANTICS }
306
+ $handle = [HelloLynkWindowsFileNative]::CreateFileW(
307
+ $PathValue,
308
+ [HelloLynkWindowsFileNative]::GENERIC_READ,
309
+ [HelloLynkWindowsFileNative]::FILE_SHARE_READ -bor
310
+ [HelloLynkWindowsFileNative]::FILE_SHARE_WRITE -bor
311
+ [HelloLynkWindowsFileNative]::FILE_SHARE_DELETE,
312
+ [IntPtr]::Zero,
313
+ [HelloLynkWindowsFileNative]::OPEN_EXISTING,
314
+ $flags,
315
+ [IntPtr]::Zero
316
+ )
317
+ if ($handle.IsInvalid) { Fail "client_file_open_failed" }
318
+ $builder = [Text.StringBuilder]::new(32768)
319
+ $length = [HelloLynkWindowsFileNative]::GetFinalPathNameByHandleW($handle, $builder, $builder.Capacity, 0)
320
+ if ($length -eq 0 -or $length -ge $builder.Capacity) { $handle.Dispose(); Fail "client_file_final_path_failed" }
321
+ $final = $builder.ToString()
322
+ if ($final.StartsWith("\\?\UNC\", [StringComparison]::OrdinalIgnoreCase)) {
323
+ $handle.Dispose(); Fail "client_root_fixed_local_required"
324
+ }
325
+ if ($final.StartsWith("\\?\", [StringComparison]::Ordinal)) { $final = $final.Substring(4) }
326
+ if (-not $final.Equals($PathValue, [StringComparison]::OrdinalIgnoreCase)) {
327
+ $handle.Dispose(); Fail "client_root_alias_rejected"
328
+ }
329
+ $information = [HelloLynkWindowsFileNative+BY_HANDLE_FILE_INFORMATION]::new()
330
+ if (-not [HelloLynkWindowsFileNative]::GetFileInformationByHandle($handle, [ref]$information)) {
331
+ $handle.Dispose(); Fail "client_file_identity_failed"
332
+ }
333
+ return [pscustomobject]@{
334
+ Handle = $handle
335
+ Volume = $information.VolumeSerialNumber
336
+ IndexHigh = $information.FileIndexHigh
337
+ IndexLow = $information.FileIndexLow
338
+ }
339
+ }
340
+
341
+ function Test-HelloLynkSameIdentity($Left, $Right) {
342
+ return (
343
+ $Left.Volume -eq $Right.Volume -and
344
+ $Left.IndexHigh -eq $Right.IndexHigh -and
345
+ $Left.IndexLow -eq $Right.IndexLow
346
+ )
347
+ }
348
+
349
+ function Enter-HelloLynkRootMutex([string]$CanonicalRoot) {
350
+ $material = [Text.Encoding]::UTF8.GetBytes($script:CurrentSid.Value + "`0" + $CanonicalRoot.ToLowerInvariant())
351
+ $sha = [Security.Cryptography.SHA256]::Create()
352
+ try { $hash = [Convert]::ToHexString($sha.ComputeHash($material)).ToLowerInvariant() } finally { $sha.Dispose() }
353
+ $mutex = [Threading.Mutex]::new($false, ("Local\HelloLynk.ClientRoot.{0}" -f $hash))
354
+ try { $acquired = $mutex.WaitOne([TimeSpan]::FromSeconds(15)) } catch [Threading.AbandonedMutexException] { $acquired = $true }
355
+ if (-not $acquired) { $mutex.Dispose(); Fail "client_root_mutex_timeout" }
356
+ return $mutex
357
+ }
358
+
359
+ function Exit-HelloLynkRootMutex($Mutex) {
360
+ if ($null -eq $Mutex) { return }
361
+ try { $Mutex.ReleaseMutex() } finally { $Mutex.Dispose() }
362
+ }
363
+
364
+ function Ensure-HelloLynkSecureDirectory([string]$PathValue) {
365
+ if ([IO.Directory]::Exists($PathValue)) {
366
+ Assert-HelloLynkNoReparseAncestors $PathValue
367
+ Assert-HelloLynkSecureAcl $PathValue $true
368
+ $identity = Open-HelloLynkIdentity $PathValue $true
369
+ $identity.Handle.Dispose()
370
+ return
371
+ }
372
+ $parent = [IO.Path]::GetDirectoryName($PathValue)
373
+ if (-not [IO.Directory]::Exists($parent)) { Ensure-HelloLynkSecureDirectory $parent }
374
+ if (-not [HelloLynkWindowsFileNative]::CreateDirectoryW($PathValue, [IntPtr]::Zero)) {
375
+ Fail "client_root_create_race"
376
+ }
377
+ Set-HelloLynkSecureAcl $PathValue $true
378
+ Assert-HelloLynkNoReparseAncestors $PathValue
379
+ Assert-HelloLynkSecureAcl $PathValue $true
380
+ $identity = Open-HelloLynkIdentity $PathValue $true
381
+ $identity.Handle.Dispose()
382
+ }
383
+
384
+ function Resolve-HelloLynkClientRoot([string]$RootValue, [bool]$Create) {
385
+ $root = Normalize-HelloLynkAbsoluteFixedPath $RootValue
386
+ Assert-HelloLynkNoReparseAncestors $root
387
+ $mutex = Enter-HelloLynkRootMutex $root
388
+ try {
389
+ Assert-HelloLynkTrustedAncestorAcl $root
390
+ if ($Create) { Ensure-HelloLynkSecureDirectory $root }
391
+ if (-not [IO.Directory]::Exists($root)) { Fail "client_root_missing" }
392
+ Assert-HelloLynkNoReparseAncestors $root
393
+ Assert-HelloLynkSecureAcl $root $true
394
+ $identity = Open-HelloLynkIdentity $root $true
395
+ try {
396
+ Assert-HelloLynkTrustedAncestorAcl $root
397
+ $canonical = $root
398
+ } finally { $identity.Handle.Dispose() }
399
+ return [pscustomobject]@{ Root = $canonical; Mutex = $mutex }
400
+ } catch {
401
+ Exit-HelloLynkRootMutex $mutex
402
+ throw
403
+ }
404
+ }
405
+
406
+ function Assert-HelloLynkPathInsideRoot([string]$Root, [string]$Candidate, [string]$Expected) {
407
+ $candidateFull = Normalize-HelloLynkAbsoluteFixedPath $Candidate
408
+ if (-not $candidateFull.Equals($Expected, [StringComparison]::OrdinalIgnoreCase)) {
409
+ Fail "client_root_path_invalid"
410
+ }
411
+ $prefix = $Root.TrimEnd([char[]]@('\')) + '\'
412
+ if (-not $candidateFull.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) {
413
+ Fail "client_root_path_invalid"
414
+ }
415
+ return $candidateFull
416
+ }
417
+
418
+ function Read-HelloLynkSecureText([string]$PathValue, [int]$MaxBytes) {
419
+ Assert-HelloLynkNoReparseAncestors $PathValue
420
+ Assert-HelloLynkSecureAcl ([IO.Path]::GetDirectoryName($PathValue)) $true
421
+ Assert-HelloLynkSecureAcl $PathValue $false
422
+ $first = Open-HelloLynkIdentity $PathValue $false
423
+ try {
424
+ $stream = [IO.FileStream]::new($first.Handle, [IO.FileAccess]::Read)
425
+ try {
426
+ if ($stream.Length -gt $MaxBytes) { Fail "client_file_too_large" }
427
+ $reader = [IO.StreamReader]::new($stream, [Text.UTF8Encoding]::new($false, $true), $true, 4096, $true)
428
+ try { $text = $reader.ReadToEnd() } finally { $reader.Dispose() }
429
+ } finally { $stream.Dispose() }
430
+ $first.Handle = $null
431
+ } finally {
432
+ if ($null -ne $first.Handle) { $first.Handle.Dispose() }
433
+ }
434
+ Assert-HelloLynkNoReparseAncestors $PathValue
435
+ Assert-HelloLynkSecureAcl $PathValue $false
436
+ $second = Open-HelloLynkIdentity $PathValue $false
437
+ try {
438
+ if (-not (Test-HelloLynkSameIdentity $first $second)) { Fail "client_file_identity_changed" }
439
+ } finally { $second.Handle.Dispose() }
440
+ return $text
441
+ }
442
+
443
+ function Remove-HelloLynkVerifiedTemp([string]$TempPath) {
444
+ if (-not [IO.File]::Exists($TempPath)) { return }
445
+ Assert-HelloLynkNoReparseAncestors $TempPath
446
+ Assert-HelloLynkSecureAcl $TempPath $false
447
+ [IO.File]::Delete($TempPath)
448
+ }
449
+
450
+ function Write-HelloLynkSecureText([string]$PathValue, [string]$TextValue) {
451
+ $parent = [IO.Path]::GetDirectoryName($PathValue)
452
+ Ensure-HelloLynkSecureDirectory $parent
453
+ if ([IO.File]::Exists($PathValue)) {
454
+ Assert-HelloLynkNoReparseAncestors $PathValue
455
+ Assert-HelloLynkSecureAcl $PathValue $false
456
+ }
457
+ $tempPath = Join-Path $parent (".{0}.{1}.tmp" -f [IO.Path]::GetFileName($PathValue), [Guid]::NewGuid().ToString("N"))
458
+ try {
459
+ $bytes = [Text.UTF8Encoding]::new($false).GetBytes($TextValue)
460
+ $stream = [IO.FileStream]::new($tempPath, [IO.FileMode]::CreateNew, [IO.FileAccess]::Write, [IO.FileShare]::None)
461
+ try {
462
+ $stream.Write($bytes, 0, $bytes.Length)
463
+ $stream.Flush($true)
464
+ } finally { $stream.Dispose() }
465
+ Set-HelloLynkSecureAcl $tempPath $false
466
+ Assert-HelloLynkNoReparseAncestors $tempPath
467
+ Assert-HelloLynkSecureAcl $tempPath $false
468
+ $tempIdentity = Open-HelloLynkIdentity $tempPath $false
469
+ $tempIdentity.Handle.Dispose()
470
+ if (-not [HelloLynkWindowsFileNative]::MoveFileExW(
471
+ $tempPath,
472
+ $PathValue,
473
+ [HelloLynkWindowsFileNative]::MOVEFILE_REPLACE_EXISTING -bor [HelloLynkWindowsFileNative]::MOVEFILE_WRITE_THROUGH
474
+ )) { Fail "client_file_replace_failed" }
475
+ Assert-HelloLynkNoReparseAncestors $PathValue
476
+ Assert-HelloLynkSecureAcl $PathValue $false
477
+ $finalIdentity = Open-HelloLynkIdentity $PathValue $false
478
+ try {
479
+ if (-not (Test-HelloLynkSameIdentity $tempIdentity $finalIdentity)) { Fail "client_file_identity_changed" }
480
+ } finally { $finalIdentity.Handle.Dispose() }
481
+ } finally {
482
+ Remove-HelloLynkVerifiedTemp $tempPath
483
+ }
484
+ }
485
+
486
+ # Returns $null when the file is absent. Anything present but unreadable fails:
487
+ # a damaged key file is never reported as missing.
488
+ function Read-HelloLynkE2eePrivateKeys([string]$KeysPath, [string]$KeysProfileId) {
489
+ Assert-HelloLynkNoReparseAncestors $KeysPath
490
+ if (-not [IO.File]::Exists($KeysPath)) { return $null }
491
+ $serialized = Read-HelloLynkSecureText $KeysPath 524288
492
+ try { $credential = [Management.Automation.PSSerializer]::Deserialize($serialized) } catch { Fail "e2ee_private_key_material_invalid" }
493
+ if (
494
+ $credential -isnot [Management.Automation.PSCredential] -or
495
+ $credential.UserName -ne ("hellolynk-direct-text-e2ee-private-keys:v2:{0}" -f $KeysProfileId)
496
+ ) { Fail "e2ee_private_key_material_binding_invalid" }
497
+ $plain = $credential.GetNetworkCredential().Password
498
+ if ([Text.Encoding]::UTF8.GetByteCount($plain) -gt 262144) { Fail "e2ee_private_key_material_too_large" }
499
+ try { $wrapper = $plain | ConvertFrom-Json } catch { Fail "e2ee_private_key_material_invalid" }
500
+ $propertyNames = @($wrapper.PSObject.Properties.Name)
501
+ if (
502
+ $propertyNames.Count -ne 3 -or
503
+ $propertyNames -notcontains "schemaVersion" -or
504
+ $propertyNames -notcontains "format" -or
505
+ $propertyNames -notcontains "privateKeyMaterial" -or
506
+ [int]$wrapper.schemaVersion -ne 2 -or
507
+ [string]$wrapper.format -ne "hellolynk_direct_text_e2ee_private_keys_v2" -or
508
+ [string]::IsNullOrWhiteSpace([string]$wrapper.privateKeyMaterial)
509
+ ) { Fail "e2ee_private_key_material_invalid" }
510
+ try { $null = [string]$wrapper.privateKeyMaterial | ConvertFrom-Json } catch { Fail "e2ee_private_key_material_invalid" }
511
+ return $plain
512
+ }
513
+
514
+ try {
515
+ if (-not $ClientRoot) {
516
+ if ($StoreRoot -and [IO.Path]::GetFileName($StoreRoot.TrimEnd([char[]]@('\', '/'))) -ieq "credentials") {
517
+ $ClientRoot = [IO.Path]::GetDirectoryName($StoreRoot.TrimEnd([char[]]@('\', '/')))
518
+ } else {
519
+ $ClientRoot = if ($StoreRoot) { $StoreRoot } elseif ($env:LOCALAPPDATA) { Join-Path $env:LOCALAPPDATA "HelloLynk" } else { "" }
520
+ }
521
+ }
522
+ if (-not $ClientRoot) { Fail "client_root_unavailable" }
523
+ $createRoot = $Command -in @(
524
+ "PrepareRoot", "StoreProfile", "StoreStatus", "Store", "StoreRuntimeSession", "StoreE2eePrivateKeys"
525
+ )
526
+ $rootSession = Resolve-HelloLynkClientRoot $ClientRoot $createRoot
527
+ $clientRootFull = $rootSession.Root
528
+
529
+ $expectedCredentialRoot = Join-Path $clientRootFull "credentials"
530
+ $expectedE2eeRoot = Join-Path $clientRootFull "e2ee-private-keys"
531
+ $storeRootFull = if ($StoreRoot) { Normalize-HelloLynkAbsoluteFixedPath $StoreRoot } else { $expectedCredentialRoot }
532
+ $e2eeStoreRootFull = if ($E2eeStoreRoot) { Normalize-HelloLynkAbsoluteFixedPath $E2eeStoreRoot } else { $expectedE2eeRoot }
533
+ if (
534
+ -not $storeRootFull.Equals($clientRootFull, [StringComparison]::OrdinalIgnoreCase) -and
535
+ -not $storeRootFull.Equals($expectedCredentialRoot, [StringComparison]::OrdinalIgnoreCase)
536
+ ) { Fail "credential_store_root_invalid" }
537
+ if (-not $e2eeStoreRootFull.Equals($expectedE2eeRoot, [StringComparison]::OrdinalIgnoreCase)) {
538
+ Fail "e2ee_private_key_store_root_invalid"
539
+ }
540
+ if ($storeRootFull.Equals($e2eeStoreRootFull, [StringComparison]::OrdinalIgnoreCase)) {
541
+ Fail "e2ee_private_key_store_not_separated"
542
+ }
543
+ if ($Command -eq "StoreE2eePrivateKeys") { Ensure-HelloLynkSecureDirectory $e2eeStoreRootFull }
544
+ if (
545
+ $Command -in @(
546
+ "LoadE2eePrivateKeys", "LoadE2eePrivateKeysBatch", "RemoveE2eePrivateKeys"
547
+ ) -and
548
+ [IO.Directory]::Exists($e2eeStoreRootFull)
549
+ ) { Ensure-HelloLynkSecureDirectory $e2eeStoreRootFull }
550
+ if ($Command -in @("Store", "StoreRuntimeSession")) {
551
+ Ensure-HelloLynkSecureDirectory $storeRootFull
552
+ }
553
+ if (
554
+ $Command -in @("Load", "Remove", "ProbeCredential", "LoadRuntimeSession", "RemoveRuntimeSession") -and
555
+ [IO.Directory]::Exists($storeRootFull)
556
+ ) { Ensure-HelloLynkSecureDirectory $storeRootFull }
557
+ $credentialPath = Join-Path $storeRootFull ("credential-{0}.dpapi.clixml" -f $ProfileId)
558
+ $runtimeSessionPath = Join-Path $storeRootFull ("runtime-session-{0}.dpapi.clixml" -f $ProfileId)
559
+ $e2eePrivateKeysPath = Join-Path $e2eeStoreRootFull ("direct-text-private-keys-{0}.dpapi.clixml" -f $ProfileId)
560
+
561
+ switch ($Command) {
562
+ "PrepareRoot" {
563
+ foreach ($child in @("profiles", "credentials", "e2ee-private-keys", "status", "core", "platform", "bin", "host", "host\runtime", "releases")) {
564
+ Ensure-HelloLynkSecureDirectory (Join-Path $clientRootFull $child)
565
+ }
566
+ Ensure-HelloLynkSecureDirectory (Join-Path $clientRootFull "platform\windows")
567
+ Ensure-HelloLynkSecureDirectory (Join-Path $clientRootFull "core\vendor")
568
+ [Console]::Out.Write("prepared")
569
+ }
570
+ { $_ -in @("SecureReleaseTree", "VerifyReleaseTree") } {
571
+ if ($ManagedRelativePath -notmatch '^releases\\(?!\.{1,2}$)[A-Za-z0-9.][A-Za-z0-9_.-]{2,191}$') {
572
+ Fail "client_release_path_invalid"
573
+ }
574
+ $releasePath = Assert-HelloLynkPathInsideRoot `
575
+ $clientRootFull `
576
+ (Join-Path $clientRootFull $ManagedRelativePath) `
577
+ (Join-Path $clientRootFull $ManagedRelativePath)
578
+ if (-not [IO.Directory]::Exists($releasePath)) { Fail "client_release_missing" }
579
+ $pending = [Collections.Generic.Stack[string]]::new()
580
+ $pending.Push($releasePath)
581
+ while ($pending.Count -gt 0) {
582
+ $directory = $pending.Pop()
583
+ Assert-HelloLynkNoReparseAncestors $directory
584
+ if ($Command -eq "SecureReleaseTree") { Set-HelloLynkSecureAcl $directory $true }
585
+ Assert-HelloLynkSecureAcl $directory $true
586
+ foreach ($item in @(Get-ChildItem -LiteralPath $directory -Force)) {
587
+ if (($item.Attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0) {
588
+ Fail "client_release_reparse_rejected"
589
+ }
590
+ if ($item.PSIsContainer) {
591
+ $pending.Push($item.FullName)
592
+ } elseif ([IO.File]::Exists($item.FullName)) {
593
+ if ($Command -eq "SecureReleaseTree") { Set-HelloLynkSecureAcl $item.FullName $false }
594
+ Assert-HelloLynkSecureAcl $item.FullName $false
595
+ } else {
596
+ Fail "client_release_entry_invalid"
597
+ }
598
+ }
599
+ }
600
+ [Console]::Out.Write($(if ($Command -eq "SecureReleaseTree") { "secured" } else { "verified" }))
601
+ }
602
+ "StoreProfile" {
603
+ $path = Join-Path $clientRootFull ("profiles\{0}.json" -f $ProfileId)
604
+ $plain = [Console]::In.ReadToEnd()
605
+ if ([Text.Encoding]::UTF8.GetByteCount($plain) -gt 65536) { Fail "client_profile_too_large" }
606
+ Write-HelloLynkSecureText $path $plain
607
+ $plain = $null
608
+ [Console]::Out.Write("stored")
609
+ }
610
+ "StoreStatus" {
611
+ $path = Join-Path $clientRootFull ("status\{0}.json" -f $ProfileId)
612
+ $plain = [Console]::In.ReadToEnd()
613
+ if ([Text.Encoding]::UTF8.GetByteCount($plain) -gt 65536) { Fail "host_status_too_large" }
614
+ Write-HelloLynkSecureText $path $plain
615
+ $plain = $null
616
+ [Console]::Out.Write("stored")
617
+ }
618
+ "ProbeCredential" {
619
+ Assert-HelloLynkNoReparseAncestors $credentialPath
620
+ if (-not [IO.File]::Exists($credentialPath)) { Fail "device_credential_missing" }
621
+ Assert-HelloLynkSecureAcl $credentialPath $false
622
+ $probe = Open-HelloLynkIdentity $credentialPath $false
623
+ $probe.Handle.Dispose()
624
+ [Console]::Out.Write("present")
625
+ }
626
+ "ValidateManagedFile" {
627
+ if (
628
+ [string]::IsNullOrWhiteSpace($ManagedRelativePath) -or
629
+ $ManagedRelativePath -notmatch '^(?:core(?:\\vendor)?|platform\\windows|bin)\\[A-Za-z0-9_.-]+$'
630
+ ) { Fail "client_managed_path_invalid" }
631
+ $path = Assert-HelloLynkPathInsideRoot `
632
+ $clientRootFull `
633
+ (Join-Path $clientRootFull $ManagedRelativePath) `
634
+ (Join-Path $clientRootFull $ManagedRelativePath)
635
+ Assert-HelloLynkNoReparseAncestors $path
636
+ if (-not [IO.File]::Exists($path)) { Fail "client_managed_file_missing" }
637
+ Assert-HelloLynkSecureAcl $path $false
638
+ $managedIdentity = Open-HelloLynkIdentity $path $false
639
+ $managedIdentity.Handle.Dispose()
640
+ [Console]::Out.Write("trusted")
641
+ }
642
+ "SecureManagedFile" {
643
+ if (
644
+ [string]::IsNullOrWhiteSpace($ManagedRelativePath) -or
645
+ $ManagedRelativePath -notmatch '^(?:core(?:\\vendor)?|platform\\windows|bin)\\[A-Za-z0-9_.-]+$'
646
+ ) { Fail "client_managed_path_invalid" }
647
+ $path = Assert-HelloLynkPathInsideRoot `
648
+ $clientRootFull `
649
+ (Join-Path $clientRootFull $ManagedRelativePath) `
650
+ (Join-Path $clientRootFull $ManagedRelativePath)
651
+ Assert-HelloLynkNoReparseAncestors $path
652
+ if (-not [IO.File]::Exists($path)) { Fail "client_managed_file_missing" }
653
+ Set-HelloLynkSecureAcl $path $false
654
+ Assert-HelloLynkSecureAcl $path $false
655
+ $managedIdentity = Open-HelloLynkIdentity $path $false
656
+ $managedIdentity.Handle.Dispose()
657
+ [Console]::Out.Write("secured")
658
+ }
659
+ "Store" {
660
+ $plain = [Console]::In.ReadToEnd()
661
+ if ($plain -notmatch '^hellolynk_device_v1_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{32,}$') {
662
+ Fail "device_credential_invalid"
663
+ }
664
+ $secure = ConvertTo-SecureString -String $plain -AsPlainText -Force
665
+ $credential = [Management.Automation.PSCredential]::new($ProfileId, $secure)
666
+ try {
667
+ $serialized = [Management.Automation.PSSerializer]::Serialize($credential)
668
+ Write-HelloLynkSecureText $credentialPath $serialized
669
+ } finally {
670
+ $plain = $null
671
+ $secure = $null
672
+ $credential = $null
673
+ $serialized = $null
674
+ }
675
+ [Console]::Out.Write("stored")
676
+ }
677
+ "Load" {
678
+ Assert-HelloLynkNoReparseAncestors $credentialPath
679
+ if (-not [IO.File]::Exists($credentialPath)) { Fail "device_credential_missing" }
680
+ $serialized = Read-HelloLynkSecureText $credentialPath 262144
681
+ try { $credential = [Management.Automation.PSSerializer]::Deserialize($serialized) } catch { Fail "device_credential_invalid" }
682
+ if ($credential -isnot [Management.Automation.PSCredential] -or $credential.UserName -ne $ProfileId) {
683
+ Fail "device_credential_binding_invalid"
684
+ }
685
+ $plain = $credential.GetNetworkCredential().Password
686
+ if ($plain -notmatch '^hellolynk_device_v1_[A-Za-z0-9_-]+\.[A-Za-z0-9_-]{32,}$') {
687
+ Fail "device_credential_invalid"
688
+ }
689
+ [Console]::Out.Write($plain)
690
+ $plain = $null
691
+ $credential = $null
692
+ $serialized = $null
693
+ }
694
+ "Remove" {
695
+ if ([IO.File]::Exists($credentialPath)) {
696
+ Assert-HelloLynkNoReparseAncestors $credentialPath
697
+ Assert-HelloLynkSecureAcl $credentialPath $false
698
+ [IO.File]::Delete($credentialPath)
699
+ }
700
+ [Console]::Out.Write("removed")
701
+ }
702
+ "StoreRuntimeSession" {
703
+ $plain = [Console]::In.ReadToEnd()
704
+ if ([Text.Encoding]::UTF8.GetByteCount($plain) -gt 65536) { Fail "runtime_session_too_large" }
705
+ try { $session = $plain | ConvertFrom-Json } catch { Fail "runtime_session_invalid" }
706
+ if (
707
+ [int]$session.schemaVersion -ne 1 -or
708
+ [string]$session.sessionId -notmatch '^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$' -or
709
+ [string]$session.runtimeId -notmatch '^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$' -or
710
+ [string]$session.principalId -notmatch '^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$' -or
711
+ [string]$session.agentId -notmatch '^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$' -or
712
+ [string]$session.deviceId -notmatch '^[A-Za-z0-9][A-Za-z0-9_.:-]{2,127}$' -or
713
+ [string]$session.sessionSecret -notmatch '^runtime_[a-f0-9]{48}$' -or
714
+ [double]$session.expiresAtMs -le 0
715
+ ) { Fail "runtime_session_invalid" }
716
+ $secure = ConvertTo-SecureString -String $plain -AsPlainText -Force
717
+ $credential = [Management.Automation.PSCredential]::new(("runtime-session:{0}" -f $ProfileId), $secure)
718
+ try {
719
+ $serialized = [Management.Automation.PSSerializer]::Serialize($credential)
720
+ Write-HelloLynkSecureText $runtimeSessionPath $serialized
721
+ } finally {
722
+ $plain = $null
723
+ $session = $null
724
+ $secure = $null
725
+ $credential = $null
726
+ $serialized = $null
727
+ }
728
+ [Console]::Out.Write("stored")
729
+ }
730
+ "LoadRuntimeSession" {
731
+ Assert-HelloLynkNoReparseAncestors $runtimeSessionPath
732
+ if (-not [IO.File]::Exists($runtimeSessionPath)) {
733
+ [Console]::Out.Write("missing")
734
+ break
735
+ }
736
+ $serialized = Read-HelloLynkSecureText $runtimeSessionPath 262144
737
+ try { $credential = [Management.Automation.PSSerializer]::Deserialize($serialized) } catch { Fail "runtime_session_invalid" }
738
+ if (
739
+ $credential -isnot [Management.Automation.PSCredential] -or
740
+ $credential.UserName -ne ("runtime-session:{0}" -f $ProfileId)
741
+ ) { Fail "runtime_session_binding_invalid" }
742
+ $plain = $credential.GetNetworkCredential().Password
743
+ try { $session = $plain | ConvertFrom-Json } catch { Fail "runtime_session_invalid" }
744
+ if (
745
+ [int]$session.schemaVersion -ne 1 -or
746
+ [string]$session.sessionSecret -notmatch '^runtime_[a-f0-9]{48}$' -or
747
+ [double]$session.expiresAtMs -le 0
748
+ ) { Fail "runtime_session_invalid" }
749
+ [Console]::Out.Write($plain)
750
+ $plain = $null
751
+ $session = $null
752
+ $credential = $null
753
+ $serialized = $null
754
+ }
755
+ "RemoveRuntimeSession" {
756
+ if ([IO.File]::Exists($runtimeSessionPath)) {
757
+ Assert-HelloLynkNoReparseAncestors $runtimeSessionPath
758
+ Assert-HelloLynkSecureAcl $runtimeSessionPath $false
759
+ [IO.File]::Delete($runtimeSessionPath)
760
+ }
761
+ [Console]::Out.Write("removed")
762
+ }
763
+ "StoreE2eePrivateKeys" {
764
+ $plain = [Console]::In.ReadToEnd()
765
+ # A SecureString holds at most 65536 characters. Larger keys fail here,
766
+ # before the existing file is touched.
767
+ if ($plain.Length -gt 65536) { Fail "e2ee_private_key_material_too_large" }
768
+ try { $wrapper = $plain | ConvertFrom-Json } catch { Fail "e2ee_private_key_material_invalid" }
769
+ $propertyNames = @($wrapper.PSObject.Properties.Name)
770
+ if (
771
+ $propertyNames.Count -ne 3 -or
772
+ $propertyNames -notcontains "schemaVersion" -or
773
+ $propertyNames -notcontains "format" -or
774
+ $propertyNames -notcontains "privateKeyMaterial" -or
775
+ [int]$wrapper.schemaVersion -ne 2 -or
776
+ [string]$wrapper.format -ne "hellolynk_direct_text_e2ee_private_keys_v2" -or
777
+ [string]::IsNullOrWhiteSpace([string]$wrapper.privateKeyMaterial) -or
778
+ [Text.Encoding]::UTF8.GetByteCount([string]$wrapper.privateKeyMaterial) -gt 98304
779
+ ) { Fail "e2ee_private_key_material_invalid" }
780
+ try {
781
+ $material = [string]$wrapper.privateKeyMaterial | ConvertFrom-Json
782
+ } catch { Fail "e2ee_private_key_material_invalid" }
783
+ if ($null -eq $material -or $material -is [Array]) { Fail "e2ee_private_key_material_invalid" }
784
+ $secure = ConvertTo-SecureString -String $plain -AsPlainText -Force
785
+ $credential = [Management.Automation.PSCredential]::new(
786
+ ("hellolynk-direct-text-e2ee-private-keys:v2:{0}" -f $ProfileId),
787
+ $secure
788
+ )
789
+ try {
790
+ $serialized = [Management.Automation.PSSerializer]::Serialize($credential)
791
+ Write-HelloLynkSecureText $e2eePrivateKeysPath $serialized
792
+ } finally {
793
+ $plain = $null
794
+ $wrapper = $null
795
+ $material = $null
796
+ $secure = $null
797
+ $credential = $null
798
+ $serialized = $null
799
+ }
800
+ [Console]::Out.Write("stored")
801
+ }
802
+ "LoadE2eePrivateKeys" {
803
+ $plain = Read-HelloLynkE2eePrivateKeys $e2eePrivateKeysPath $ProfileId
804
+ [Console]::Out.Write($(if ($null -eq $plain) { "missing" } else { $plain }))
805
+ $plain = $null
806
+ }
807
+ "LoadE2eePrivateKeysBatch" {
808
+ # stdin: a JSON array of distinct profile IDs. One root check and one
809
+ # process serve every profile; any damaged file fails the whole batch.
810
+ try { $requested = [Console]::In.ReadToEnd() | ConvertFrom-Json -NoEnumerate } catch { Fail "e2ee_private_key_batch_invalid" }
811
+ if ($requested -isnot [Array] -or $requested.Count -lt 1 -or $requested.Count -gt 64) { Fail "e2ee_private_key_batch_invalid" }
812
+ $seen = [Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
813
+ foreach ($id in $requested) {
814
+ if ($id -isnot [string] -or $id -notmatch '^[A-Za-z0-9][A-Za-z0-9_.-]{2,127}$' -or -not $seen.Add($id)) {
815
+ Fail "e2ee_private_key_batch_invalid"
816
+ }
817
+ }
818
+ $entries = [Collections.Generic.List[string]]::new()
819
+ foreach ($id in $requested) {
820
+ $plain = Read-HelloLynkE2eePrivateKeys (Join-Path $e2eeStoreRootFull ("direct-text-private-keys-{0}.dpapi.clixml" -f $id)) $id
821
+ $entries.Add($(if ($null -eq $plain) { '{{"profileId":"{0}","state":"missing"}}' -f $id } else {
822
+ '{{"profileId":"{0}","state":"present","value":"{1}"}}' -f $id, [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($plain))
823
+ }))
824
+ $plain = $null
825
+ }
826
+ [Console]::Out.Write("[" + ($entries -join ",") + "]")
827
+ $entries = $null
828
+ }
829
+ "RemoveE2eePrivateKeys" {
830
+ if ([IO.File]::Exists($e2eePrivateKeysPath)) {
831
+ Assert-HelloLynkNoReparseAncestors $e2eePrivateKeysPath
832
+ Assert-HelloLynkSecureAcl $e2eePrivateKeysPath $false
833
+ [IO.File]::Delete($e2eePrivateKeysPath)
834
+ }
835
+ [Console]::Out.Write("removed")
836
+ }
837
+ }
838
+ } catch {
839
+ $message = [string]$_.Exception.Message
840
+ if ($message -match '^[a-z][a-z0-9_]{0,127}$') { Fail $message }
841
+ Fail "credential_store_operation_failed"
842
+ } finally {
843
+ if ($null -ne $rootSession) { Exit-HelloLynkRootMutex $rootSession.Mutex }
844
+ }