#requires -Version 7.0 # Native yink Secure Send CLI. No Node, Python, downloaded code or crypto dependency. # Dot-source for protocol tests; execute with pwsh -NoProfile -File yink.ps1 ... # The downloadable artifact is generated byte-for-byte by tools/ports/powershell/build.ps1. $script:YinkScriptPath = $PSCommandPath function Initialize-YinkCrypto { if (-not ('YinkNative.Crypto' -as [type])) { Add-Type -TypeDefinition @' using System; using System.IO; using System.Text; using System.Security.Cryptography; // Constructors retain PowerShell 7.0 / .NET Core 3.1 compatibility. Both GCM // operations always supply a 16-byte tag; PBKDF2 explicitly uses SHA-256. #pragma warning disable SYSLIB0053, SYSLIB0060 namespace YinkNative { public static class Crypto { public static byte[] Random(int length) { var bytes = new byte[length]; using (var rng = RandomNumberGenerator.Create()) rng.GetBytes(bytes); return bytes; } public static byte[] Utf8(string text) { return new UTF8Encoding(false, true).GetBytes(text); } public static string Text(byte[] bytes) { // TextDecoder's default ignoreBOM=false strips a UTF-8 BOM. int start = bytes.Length >= 3 && bytes[0] == 239 && bytes[1] == 187 && bytes[2] == 191 ? 3 : 0; return new UTF8Encoding(false, true).GetString(bytes, start, bytes.Length - start); } public static byte[] ReadBounded(Stream stream, int limit) { using (var result = new MemoryStream()) { var chunk = new byte[4096]; int count; while ((count = stream.Read(chunk, 0, Math.Min(chunk.Length, limit + 1 - (int)result.Length))) > 0) { result.Write(chunk, 0, count); if (result.Length > limit) throw new InvalidDataException("input-too-large"); } return result.ToArray(); } } public static string B64(byte[] bytes) { return Convert.ToBase64String(bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_'); } public static byte[] Unb64(string value, int length = -1) { if (value == null || !System.Text.RegularExpressions.Regex.IsMatch(value, "^[A-Za-z0-9_-]+$") || value.Length % 4 == 1) throw new ArgumentException("Invalid encoded data."); var bytes = Convert.FromBase64String(value.Replace('-', '+').Replace('_', '/') + new string('=', (4 - value.Length % 4) % 4)); if ((length >= 0 && bytes.Length != length) || B64(bytes) != value) throw new ArgumentException("Invalid encoded data."); return bytes; } public static string Hex(byte[] bytes) { return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant(); } public static byte[] Hash(byte[] bytes) { using (var hash = SHA256.Create()) return hash.ComputeHash(bytes); } public static byte[] Hmac(byte[] key, byte[] bytes) { using (var mac = new HMACSHA256(key)) return mac.ComputeHash(bytes); } public static byte[] Hkdf(byte[] ikm, byte[] salt, byte[] info) { var prk = Hmac(salt, ikm); var block = new byte[info.Length + 1]; Buffer.BlockCopy(info, 0, block, 0, info.Length); block[block.Length - 1] = 1; try { return Hmac(prk, block); } finally { Array.Clear(prk, 0, prk.Length); } } public static byte[] Key(string secret, string passphrase, string salt64, string id, bool factor) { var link = Unb64(secret, 32); var salt = Unb64(salt64, 16); byte[] material = link; try { if (factor) { if (String.IsNullOrEmpty(passphrase) || Utf8(passphrase).Length > 1024) throw new ArgumentException("Invalid passphrase."); var prefix = Utf8("yink-send-pass|v1|"); var passSalt = new byte[prefix.Length + salt.Length]; Buffer.BlockCopy(prefix, 0, passSalt, 0, prefix.Length); Buffer.BlockCopy(salt, 0, passSalt, prefix.Length, salt.Length); var password = Utf8(passphrase); byte[] stretched; try { using (var pbkdf = new Rfc2898DeriveBytes(password, passSalt, 600000, HashAlgorithmName.SHA256)) stretched = pbkdf.GetBytes(32); } finally { Array.Clear(password, 0, password.Length); } material = new byte[64]; Buffer.BlockCopy(link, 0, material, 0, 32); Buffer.BlockCopy(stretched, 0, material, 32, 32); Array.Clear(stretched, 0, stretched.Length); } return Hkdf(material, salt, Utf8("yink-send-key|v1|" + id)); } finally { Array.Clear(material, 0, material.Length); Array.Clear(link, 0, link.Length); } } public static byte[] Encrypt(byte[] key, byte[] nonce, byte[] aad, byte[] plain) { var result = new byte[plain.Length + 16]; var ciphertext = new byte[plain.Length]; var tag = new byte[16]; using (var aes = new AesGcm(key)) aes.Encrypt(nonce, plain, ciphertext, tag, aad); Buffer.BlockCopy(ciphertext, 0, result, 0, ciphertext.Length); Buffer.BlockCopy(tag, 0, result, ciphertext.Length, 16); return result; } public static byte[] Decrypt(byte[] key, byte[] nonce, byte[] aad, byte[] sealedBytes) { if (sealedBytes.Length < 16) throw new CryptographicException(); var ciphertext = new byte[sealedBytes.Length - 16]; var tag = new byte[16]; var plain = new byte[ciphertext.Length]; Buffer.BlockCopy(sealedBytes, 0, ciphertext, 0, ciphertext.Length); Buffer.BlockCopy(sealedBytes, ciphertext.Length, tag, 0, 16); using (var aes = new AesGcm(key)) aes.Decrypt(nonce, ciphertext, tag, plain, aad); return plain; } } } '@ -WarningAction SilentlyContinue -ErrorAction Stop } } $script:YinkUsage = @' yink — native PowerShell 7+ Secure Send CLI yink send [--title-file PATH|- | --title-env NAME] [--text-file PATH|-] [--field-file LABEL=PATH]... [--field-env LABEL=NAME]... [--expires 1h|1d|3d|7d] [--max-reads N] [--no-recipient-delete] yink receive '' [--delete] yink revoke '' yink install [--directory PATH] [--json] (also: yink --install) Passphrase: --passphrase-env NAME | --passphrase-file PATH|- | --passphrase-prompt Common: --origin URL --timeout MS --json --help Install uses only this local script; it prints an exact per-user PATH command. Content and passphrases come from files, environment, stdin or a hidden prompt. Links are bearer secrets; quote them and keep them out of shell history. share, pull and term are recognized but unavailable in this port. They fail locally before connecting. Use the npm CLI for these commands. See POWERSHELL-CLI.md. '@ function Stop-Yink { param([string]$Code, [string]$Message, [string]$Class = 'usage', $Details = $null) $errorObject = [Exception]::new($Message) $errorObject.Data['yink-code'] = $Code; $errorObject.Data['yink-class'] = $Class if ($null -ne $Details) { $errorObject.Data['yink-details'] = $Details } throw $errorObject } function ConvertTo-YinkJson { param($Value) ConvertTo-Json -InputObject $Value -Depth 30 -Compress } function ConvertFrom-YinkJsonElement { param([System.Text.Json.JsonElement]$Element) switch ($Element.ValueKind.ToString()) { Object { $value = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::Ordinal) foreach ($property in $Element.EnumerateObject()) { $value[$property.Name] = ConvertFrom-YinkJsonElement $property.Value } return ,$value } Array { $value = [Collections.Generic.List[object]]::new() foreach ($item in $Element.EnumerateArray()) { $value.Add((ConvertFrom-YinkJsonElement $item)) } return ,($value.ToArray()) } String { return $Element.GetString() } Number { $integer = 0L if ($Element.TryGetInt64([ref]$integer)) { return $integer } return $Element.GetDouble() } True { return $true } False { return $false } Null { return $null } default { throw 'Invalid JSON.' } } } function ConvertFrom-YinkJson { param([string]$Text) # ConvertFrom-Json turns ISO-looking secrets into DateTime on some PS # versions. JsonDocument preserves strings and case-sensitive object keys. $document = [System.Text.Json.JsonDocument]::Parse($Text) try { return ConvertFrom-YinkJsonElement $document.RootElement } finally { $document.Dispose() } } function Test-YinkVisibleContent { param([string]$Value) # ECMAScript \S, rather than .NET \S: BOM is whitespace, NEL is content. [regex]::IsMatch($Value, '[^\u0009-\u000d\u0020\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]') } function Get-YinkSafeText { param([string]$Value, [switch]$KeepLines) [regex]::Replace($Value, '[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2066-\u2069\ufeff\u00ad]', { param($match) if ($KeepLines -and $match.Value -in @("`n", "`t")) { return $match.Value } '\u{0:x4}' -f [int][char]$match.Value }) } function Write-YinkRaw { param([string]$Text) $bytes = [Text.UTF8Encoding]::new($false,$true).GetBytes($Text); $script:YinkOut.Write($bytes, 0, $bytes.Length) } function Write-YinkEvent { param([string]$Event, $Fields = @{}, $Human = $null, [switch]$Exact) $record = [ordered]@{ event = $Event; at = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ') } foreach ($key in $Fields.Keys) { $record[$key] = $Fields[$key] } if ($script:YinkJson) { $json = ConvertTo-YinkJson $record $json = [regex]::Replace($json, '[\u0080-\u009f\u061c\u200e\u200f\u2028\u2029\u202a-\u202e\u2066-\u2069]', { param($m) '\u{0:x4}' -f [int][char]$m.Value }) Write-YinkRaw ($json + "`n") } elseif ($null -ne $Human) { if ($Exact) { Write-YinkRaw $Human } else { Write-YinkRaw (($Human -replace '\r?\n$', '') + "`n") } } } function Write-YinkInfo { param([string]$Text) if (-not $script:YinkJson) { [Console]::Error.WriteLine($Text) } } function Test-YinkInteger { param($Value, [double]$Minimum = 0, [double]$Maximum = 9007199254740991) ($Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) -and $Value -ge $Minimum -and $Value -le $Maximum -and [Math]::Truncate([double]$Value) -eq $Value } function Test-YinkKeys { param($Value, [string[]]$Keys) $Value -is [System.Collections.IDictionary] -and $Value.Count -eq $Keys.Count -and @($Keys | Where-Object { $Value.Keys -cnotcontains $_ }).Count -eq 0 } function Get-YinkOrigin { param([string]$Raw, [switch]$AllowRemoteHttp) $url = $null if (-not [Uri]::TryCreate($Raw, [UriKind]::Absolute, [ref]$url) -or $url.Scheme -cnotin @('http', 'https') -or $url.UserInfo) { Stop-Yink 'bad-origin' 'Secure Send needs a complete HTTPS origin' } $ipv4 = $url.DnsSafeHost -match '^127\.(?:\d{1,3}\.){2}\d{1,3}$' -and @($url.DnsSafeHost.Split('.') | Where-Object { [int]$_ -gt 255 }).Count -eq 0 $loopback = $url.DnsSafeHost -ieq 'localhost' -or $url.DnsSafeHost -in @('::1', '[::1]') -or $ipv4 if ($url.Scheme -eq 'http' -and -not $loopback -and -not $AllowRemoteHttp) { Stop-Yink 'insecure-send-origin' 'Secure Send requires HTTPS; HTTP is allowed only for localhost, 127.0.0.0/8, or ::1 development origins' } $url.GetLeftPart([UriPartial]::Authority) } function Get-YinkArgs { param([string[]]$Argv) if (-not $Argv.Count -or $Argv[0] -cin @('help', '--help')) { return @{ command = 'help'; help = $true } } $command = if ($Argv[0] -ceq '--install') { 'install' } else { $Argv[0] } $passKeys = @('passphrase-env', 'passphrase-file', 'passphrase-prompt') $allowed = @{ share = @('origin','timeout','json','help','state','rtc-backend','note','relay-only','resume') pull = @('origin','timeout','json','help','state','rtc-backend','out','only','list','note') term = @('origin','json','help','rtc-backend','shell') send = @('origin','timeout','json','help','title-file','title-env','text-file','field-file','field-env','expires','max-reads','no-recipient-delete') + $passKeys receive = @('origin','timeout','json','help','delete') + $passKeys revoke = @('origin','timeout','json','help') install = @('directory','json','help') } if ($command -cnotin $allowed.Keys) { Stop-Yink 'unknown-command' "unknown command: $(Get-YinkSafeText $command)" } $valueOptions = @('origin','timeout','out','only','shell','state','rtc-backend','title-file','title-env','text-file','field-file','field-env','expires','max-reads','passphrase-env','passphrase-file','resume','directory') $boolOptions = @('json','help','relay-only','list','no-recipient-delete','passphrase-prompt','delete') $opts = @{ command = $command; paths = [Collections.Generic.List[string]]::new(); json = $false; help = $false; fields = [Collections.Generic.List[object]]::new() } for ($i = 1; $i -lt $Argv.Count; $i++) { $raw = $Argv[$i] if (-not $raw.StartsWith('--')) { $opts.paths.Add($raw); continue } $split = $raw.Substring(2).Split('=', 2); $key = $split[0] $boolean = $key -cin $boolOptions -or ($key -ceq 'note' -and $command -ceq 'pull') $takesValue = $key -cin $valueOptions -or ($key -ceq 'note' -and $command -ceq 'share') if (-not $boolean -and -not $takesValue) { Stop-Yink 'unknown-option' "unknown option --$(Get-YinkSafeText $key)" } if ($boolean) { if ($split.Count -gt 1) { Stop-Yink 'bad-option' "--$key does not take a value" }; $value = $true } else { if ($split.Count -gt 1) { $value = $split[1] } else { $i++; $value = if ($i -lt $Argv.Count) { $Argv[$i] } else { $null } } if (-not $value -or ($split.Count -eq 1 -and $value.StartsWith('--'))) { Stop-Yink 'missing-option-value' "--$key needs a value" } } if ($key -cnotin $allowed[$command]) { Stop-Yink "bad-$command-option" "--$key does not apply to $command" } if ($key -cin @('field-file','field-env')) { $spec = $value.Split('=', 2) if ($spec.Count -ne 2 -or -not $spec[1]) { Stop-Yink 'bad-field' "--$key needs LABEL=$(if ($key -eq 'field-env') {'NAME'} else {'PATH'})" } $opts.fields.Add(@{ label = $spec[0]; source = $spec[1]; kind = if ($key -eq 'field-env') {'env'} else {'file'} }); continue } if ($opts.ContainsKey($key) -and $opts[$key] -cne $false) { Stop-Yink 'duplicate-option' "--$key was given twice" } $opts[$key] = $value } foreach ($key in @('timeout','only')) { if ($opts.ContainsKey($key)) { $number = 0.0 $valid = [double]::TryParse($opts[$key], [Globalization.NumberStyles]::Float, [Globalization.CultureInfo]::InvariantCulture, [ref]$number) if (-not $valid -or -not (Test-YinkInteger $number $(if ($key -eq 'timeout') {1} else {0}))) { if ($key -eq 'timeout') { Stop-Yink 'bad-timeout' '--timeout must be a positive integer in milliseconds' } Stop-Yink 'bad-item' '--only must be a non-negative item id' }; $opts[$key] = [long]$number } } if ($opts.ContainsKey('origin')) { $opts.origin = Get-YinkOrigin $opts.origin -AllowRemoteHttp } if ($opts.help) { return $opts } if ($command -eq 'install' -and $opts.paths.Count) { Stop-Yink 'install-operand' 'install takes no operands; use --directory PATH to choose the per-user command directory' } if ($command -eq 'share') { if ($opts.resume) { if ($opts.paths.Count) { Stop-Yink 'resume-with-paths' 'share --resume cannot also take file paths' } if ($opts.ContainsKey('note')) { Stop-Yink 'resume-with-note' 'share --resume restores the saved note' } } elseif (-not $opts.paths.Count) { Stop-Yink 'missing-path' 'share needs at least one path, or --resume ' } } if ($command -eq 'pull' -and $opts.paths.Count -ne 1) { Stop-Yink 'missing-link' 'pull needs exactly one complete share link' } if ($command -eq 'term' -and $opts.paths.Count -ne 1) { Stop-Yink 'missing-code' 'term needs one quoted six-word code' } if ($command -in @('send','receive') -and @($passKeys | Where-Object { $opts.ContainsKey($_) }).Count -gt 1) { Stop-Yink 'duplicate-passphrase' 'give at most one of --passphrase-env, --passphrase-file and --passphrase-prompt' } if ($command -eq 'send') { if ($opts.paths.Count) { Stop-Yink 'send-value-on-command-line' 'send never takes values as arguments; use stdin, --text-file, --field-file or --field-env' } $expiries = @{ '1h' = 3600; '1d' = 86400; '3d' = 259200; '7d' = 604800 } if ($opts.ContainsKey('expires') -and $opts.expires -cnotin $expiries.Keys) { Stop-Yink 'bad-expires' '--expires must be one of 1h, 1d, 3d or 7d' } $opts.ttl = if ($opts.ContainsKey('expires')) { $expiries[$opts.expires] } else { 259200 } $opts.maxReads = $null if ($opts.ContainsKey('max-reads')) { $number = 0L if ($opts['max-reads'] -cnotmatch '^\d+$' -or -not [long]::TryParse($opts['max-reads'], [ref]$number) -or $number -lt 1 -or $number -gt 100) { Stop-Yink 'bad-max-reads' '--max-reads must be an integer from 1 to 100' }; $opts.maxReads = $number } if ($opts.ContainsKey('title-file') -and $opts.ContainsKey('title-env')) { Stop-Yink 'duplicate-title' 'give at most one of --title-file and --title-env' } $stdinSources = @($opts['title-file'], $opts['text-file'], $opts['passphrase-file']) + @($opts.fields | Where-Object kind -eq 'file' | ForEach-Object source) if (@($stdinSources | Where-Object { $_ -eq '-' }).Count -gt 1) { Stop-Yink 'stdin-reused' 'only one option can read from stdin (-)' } } if ($command -in @('receive','revoke')) { if ($opts.paths.Count -ne 1) { Stop-Yink 'missing-link' "$command needs exactly one complete Secure Send link" }; $opts.link = $opts.paths[0] } return $opts } function Get-YinkCapabilityHash { param([string]$Token) [YinkNative.Crypto]::Hex([YinkNative.Crypto]::Hash([YinkNative.Crypto]::Unb64($Token, 32))) } function Get-YinkPolicy { param($Envelope) $policy = [ordered]@{} foreach ($key in @('v','id','ttl','maxReads','passphrase','salt','readHash','revokeHash','recipientHash')) { $policy[$key] = $Envelope[$key] } return $policy } function Assert-YinkEnvelope { param($Envelope, [string]$Id) if (-not (Test-YinkKeys $Envelope @('v','id','ttl','maxReads','passphrase','salt','readHash','revokeHash','recipientHash','nonce','ciphertext'))) { throw 'Invalid send fields.' } if (-not (Test-YinkInteger $Envelope.v 1 1) -or $Envelope.id -isnot [string] -or $Envelope.id -cnotmatch '^[0-9a-f]{32}$' -or $Envelope.id -cne $Id -or -not (Test-YinkInteger $Envelope.ttl) -or $Envelope.ttl -notin @(3600,86400,259200,604800)) { throw 'Invalid send policy.' } if ($null -ne $Envelope.maxReads -and -not (Test-YinkInteger $Envelope.maxReads 1 100)) { throw 'Invalid retrieval cap.' } if ($Envelope.passphrase -isnot [bool]) { throw 'Invalid capabilities.' } foreach ($key in @('readHash','revokeHash','recipientHash')) { if ($key -eq 'recipientHash' -and $null -eq $Envelope[$key]) { continue } if ($Envelope[$key] -isnot [string] -or $Envelope[$key] -cnotmatch '^[0-9a-f]{64}$') { throw 'Invalid capabilities.' } } if ($Envelope.salt -isnot [string] -or $Envelope.nonce -isnot [string]) { throw 'Invalid encoded data.' } $null = [YinkNative.Crypto]::Unb64($Envelope.salt,16); $null = [YinkNative.Crypto]::Unb64($Envelope.nonce,12) if ($Envelope.ciphertext -isnot [string] -or $Envelope.ciphertext.Length -gt 32790) { throw 'Send is too large.' } $bytes = [YinkNative.Crypto]::Unb64($Envelope.ciphertext,-1) if ($bytes.Length -lt 17 -or $bytes.Length -gt 24592) { throw 'Send is too large.' } } function Assert-YinkContent { param($Content, $RecipientHash) $legacy = Test-YinkKeys $Content @('title','fields','recipientToken') if ((-not $legacy -and -not (Test-YinkKeys $Content @('title','text','fields','recipientToken'))) -or (-not $legacy -and $Content.text -isnot [string]) -or $Content.title -isnot [string] -or $Content.title.Length -gt 256 -or $Content.fields -isnot [array] -or $Content.fields.Count -gt 8 -or ($legacy -and $Content.fields.Count -lt 1)) { throw 'Invalid encrypted content.' } foreach ($field in $Content.fields) { if (-not (Test-YinkKeys $field @('label','value')) -or $field.label -isnot [string] -or $field.label.Length -gt 128 -or $field.value -isnot [string]) { throw 'Invalid encrypted field.' } } if ($null -eq $RecipientHash) { if ($null -ne $Content.recipientToken) { throw 'Invalid deletion policy.' } } else { if ($Content.recipientToken -isnot [string]) { throw 'Invalid deletion policy.' }; $null = [YinkNative.Crypto]::Unb64($Content.recipientToken,32) } } function Get-YinkLink { param([string]$Raw, [string]$Kind) $url = $null if (-not [Uri]::TryCreate($Raw, [UriKind]::Absolute, [ref]$url) -or $url.Scheme -cnotin @('http','https') -or $url.UserInfo -or $url.AbsolutePath -cne '/send' -or $url.Query) { Stop-Yink 'bad-send-link' 'expected a complete Secure Send link (https://…/send#…)' } $origin = Get-YinkOrigin ($url.GetLeftPart([UriPartial]::Authority)) try { $fragment = $url.Fragment.TrimStart('#') if ($fragment.Length -gt 300) { throw 'length' } $link = [Collections.Generic.Dictionary[string,object]]::new([StringComparer]::Ordinal) foreach ($entry in $fragment.Split('&')) { if (-not $entry) { continue } $pair = $entry.Split('=',2); $key = [Uri]::UnescapeDataString($pair[0].Replace('+',' ')) $value = if ($pair.Count -gt 1) { [Uri]::UnescapeDataString($pair[1].Replace('+',' ')) } else { '' } $link.Add($key,$value) } if ($link['v'] -cne '1' -or $link['i'] -cnotmatch '^[0-9a-f]{32}$') { throw 'id' } if ($link.ContainsKey('d')) { if (-not (Test-YinkKeys $link @('v','i','d'))) { throw 'keys' }; $null = [YinkNative.Crypto]::Unb64($link['d'],32) $parsed = @{ id = $link['i']; revoke = $link['d'] } } else { if (-not (Test-YinkKeys $link @('v','i','k','r','p')) -or $link['p'] -cnotin @('0','1')) { throw 'keys' } $null = [YinkNative.Crypto]::Unb64($link['k'],32); $null = [YinkNative.Crypto]::Unb64($link['r'],32) $parsed = @{ id = $link['i']; secret = $link['k']; read = $link['r']; passphrase = $link['p'] -ceq '1' } } } catch { Stop-Yink 'bad-send-link' 'the Secure Send link is incomplete or damaged' } if ($Kind -eq 'recipient' -and $parsed.revoke) { Stop-Yink 'management-link' 'that is a sender management link; use `yink revoke` with it' } if ($Kind -eq 'management' -and -not $parsed.revoke) { Stop-Yink 'recipient-link' 'that is a recipient link; `yink revoke` needs the sender management link' } return @{ origin = $origin; link = $parsed } } function New-YinkSend { param($Content, [int]$Ttl, $MaxReads, [bool]$RecipientDelete, [string]$Passphrase = '', [scriptblock]$Random = { param($n) [YinkNative.Crypto]::Random($n) }) if ($Passphrase.Length -and [YinkNative.Crypto]::Utf8($Passphrase).Length -gt 1024) { throw 'Invalid options or passphrase too long.' } if ($Content.fields | Where-Object { $_.value -ceq '' }) { throw 'Each added field needs a value. Fill it in or remove it.' } if (-not (Test-YinkVisibleContent $Content.title) -and -not (Test-YinkVisibleContent $Content.text) -and -not @($Content.fields | Where-Object { Test-YinkVisibleContent $_.value }).Count) { throw 'Add a title, some text or a field before creating a send.' } $id = [YinkNative.Crypto]::Hex((& $Random 16)); $secret = [YinkNative.Crypto]::B64((& $Random 32)) $read = [YinkNative.Crypto]::B64((& $Random 32)); $revoke = [YinkNative.Crypto]::B64((& $Random 32)) $recipient = if ($RecipientDelete) { [YinkNative.Crypto]::B64((& $Random 32)) } else { $null } $envelope = [ordered]@{ v = 1; id = $id; ttl = $Ttl; maxReads = $MaxReads; passphrase = $Passphrase.Length -gt 0; salt = [YinkNative.Crypto]::B64((& $Random 16)) readHash = Get-YinkCapabilityHash $read; revokeHash = Get-YinkCapabilityHash $revoke; recipientHash = if ($recipient) { Get-YinkCapabilityHash $recipient } else { $null } } $plainContent = [ordered]@{ title = $Content.title; text = $Content.text; fields = @($Content.fields); recipientToken = $recipient } Assert-YinkContent $plainContent $envelope.recipientHash $plain = [YinkNative.Crypto]::Utf8((ConvertTo-YinkJson $plainContent)) try { if ($plain.Length -gt 24576) { throw 'Keep the title, text, labels and values within 24 KiB of encoded text.' } $nonce = & $Random 12 $key = [YinkNative.Crypto]::Key($secret,$Passphrase,$envelope.salt,$id,$envelope.passphrase) try { $sealedBytes = [YinkNative.Crypto]::Encrypt($key,$nonce,[YinkNative.Crypto]::Utf8((ConvertTo-YinkJson (Get-YinkPolicy $envelope))),$plain) } finally { [Array]::Clear($key,0,$key.Length) } $envelope.nonce = [YinkNative.Crypto]::B64($nonce); $envelope.ciphertext = [YinkNative.Crypto]::B64($sealedBytes) Assert-YinkEnvelope $envelope $id return @{ envelope = $envelope; recipientFragment = "v=1&i=$id&k=$secret&r=$read&p=$(if ($envelope.passphrase) {'1'} else {'0'})"; senderFragment = "v=1&i=$id&d=$revoke"; revoke = $revoke } } finally { [Array]::Clear($plain,0,$plain.Length) } } function Open-YinkSend { param($Envelope, $Link, [string]$Passphrase = '') Assert-YinkEnvelope $Envelope $Link.id $key = [YinkNative.Crypto]::Key($Link.secret,$Passphrase,$Envelope.salt,$Link.id,$Envelope.passphrase) $plain = $null try { $plain = [YinkNative.Crypto]::Decrypt($key,[YinkNative.Crypto]::Unb64($Envelope.nonce,12),[YinkNative.Crypto]::Utf8((ConvertTo-YinkJson (Get-YinkPolicy $Envelope))),[YinkNative.Crypto]::Unb64($Envelope.ciphertext,-1)) $content = ConvertFrom-YinkJson ([YinkNative.Crypto]::Text($plain)) Assert-YinkContent $content $Envelope.recipientHash if ($content.recipientToken -and (Get-YinkCapabilityHash $content.recipientToken) -cne $Envelope.recipientHash) { throw 'Invalid deletion capability.' } if (-not $content.ContainsKey('text')) { $content.text = '' } return $content } catch { throw $(if ($Envelope.passphrase) { 'Could not unlock. Check the passphrase and original link.' } else { 'Could not unlock. Check the original link.' }) } finally { [Array]::Clear($key,0,$key.Length); if ($plain) { [Array]::Clear($plain,0,$plain.Length) } } } function Read-YinkText { param([string]$Source, [string]$What) $stream = $null try { if ($Source -eq '-') { $stream = [Console]::OpenStandardInput() } else { try { $stream = [IO.File]::Open([IO.Path]::GetFullPath($Source),[IO.FileMode]::Open,[IO.FileAccess]::Read,[IO.FileShare]::ReadWrite) } catch { Stop-Yink 'read-failed' "cannot read ${What}: file unavailable" 'local' } } try { $bytes = [YinkNative.Crypto]::ReadBounded($stream,98304) } catch { if ($_.Exception.ToString().Contains('input-too-large')) { Stop-Yink 'send-too-large' "$What is larger than a Secure Send can hold" }; Stop-Yink 'read-failed' "cannot read ${What}: input unavailable" 'local' } try { [YinkNative.Crypto]::Text($bytes) } catch { Stop-Yink 'not-utf8' "$What is not UTF-8 text; Secure Send carries text only" } finally { [Array]::Clear($bytes,0,$bytes.Length) } } finally { if ($stream -and $Source -ne '-') { $stream.Dispose() } } } function Get-YinkEnv { param([string]$Name) $value = [Environment]::GetEnvironmentVariable($Name) if ($null -eq $value) { Stop-Yink 'missing-env' "environment variable $(Get-YinkSafeText $Name) is not set" }; return $value } function Read-YinkHidden { param([string]$Question) if ([Console]::IsInputRedirected) { Stop-Yink 'passphrase-prompt-unavailable' 'a passphrase prompt needs an interactive terminal; use --passphrase-env or --passphrase-file' } $wasControlInput = [Console]::TreatControlCAsInput; $value = [Text.StringBuilder]::new() try { [Console]::TreatControlCAsInput = $true; [Console]::Error.Write($Question) while ($true) { $key = [Console]::ReadKey($true) if ($key.KeyChar -eq [char]3) { Stop-Yink 'cancelled' 'cancelled at the passphrase prompt' 'local' } if ($key.Key -eq [ConsoleKey]::Enter -or $key.KeyChar -eq [char]4) { return $value.ToString() } if ($key.Key -eq [ConsoleKey]::Backspace) { if ($value.Length) { $length = $value.Length - 1 if ($length -gt 0 -and [char]::IsLowSurrogate($value[$length]) -and [char]::IsHighSurrogate($value[$length-1])) { $length-- } $value.Length = $length } } elseif ([int]$key.KeyChar -ge 32 -and [int]$key.KeyChar -ne 127) { $null = $value.Append($key.KeyChar) } } } finally { [Console]::TreatControlCAsInput = $wasControlInput; [Console]::Error.WriteLine() } } function Assert-YinkPassphrase { param([string]$Value) if (-not $Value) { Stop-Yink 'empty-passphrase' 'the passphrase is empty' } if ($Value -match '[\r\n]') { Stop-Yink 'bad-passphrase' 'a passphrase cannot contain a line break' } if ([YinkNative.Crypto]::Utf8($Value).Length -gt 1024) { Stop-Yink 'passphrase-too-long' 'a passphrase can be at most 1,024 UTF-8 bytes' }; return $Value } function Get-YinkPassphrase { param($Options, [switch]$Confirm) if ($Options.ContainsKey('passphrase-env')) { return Assert-YinkPassphrase (Get-YinkEnv $Options['passphrase-env']) } if ($Options.ContainsKey('passphrase-file')) { return Assert-YinkPassphrase ((Read-YinkText $Options['passphrase-file'] 'the passphrase file') -replace '\r?\n$','') } if ($Options['passphrase-prompt']) { $value = Assert-YinkPassphrase (Read-YinkHidden 'Passphrase: ') if ($Confirm -and (Read-YinkHidden 'Repeat passphrase: ') -cne $value) { Stop-Yink 'passphrase-mismatch' 'the two passphrases differ' }; return $value } return $null } function Invoke-YinkRequest { param([string]$Origin, [string]$Id, [string]$Method, [string]$Token, $Body = $null, [string]$Action = '', [long]$Timeout = 30000) $handler = [Net.Http.HttpClientHandler]::new(); $handler.AllowAutoRedirect = $false; $handler.UseCookies = $false $client = [Net.Http.HttpClient]::new($handler); $client.Timeout = [Threading.Timeout]::InfiniteTimeSpan $cts = [Threading.CancellationTokenSource]::new(); $request = $null; $response = $null; $status = $null try { # Avoid Timer's platform limit and TimeSpan overflow for safe-integer CLI inputs. $cts.CancelAfter([TimeSpan]::FromMilliseconds([Math]::Min($Timeout,2147483647))) $request = [Net.Http.HttpRequestMessage]::new([Net.Http.HttpMethod]::new($Method), "$Origin/api/send/$Id$(if ($Action) { '/' + $Action })") $request.Headers.Authorization = [Net.Http.Headers.AuthenticationHeaderValue]::new('Bearer',$Token) if ($null -ne $Body) { $request.Content = [Net.Http.StringContent]::new((ConvertTo-YinkJson $Body),[Text.Encoding]::UTF8,'application/json') # The production API deliberately accepts exactly application/json. $request.Content.Headers.ContentType.CharSet = $null } $response = $client.SendAsync($request,[Net.Http.HttpCompletionOption]::ResponseHeadersRead,$cts.Token).GetAwaiter().GetResult() $status = [int]$response.StatusCode $stream = $response.Content.ReadAsStreamAsync().GetAwaiter().GetResult() # Read asynchronously with the SAME per-request timeout; a slow body cannot hang indefinitely. $buffer = [byte[]]::new(65537); $count = 0 while ($count -lt $buffer.Length) { $read = $stream.ReadAsync($buffer,$count,$buffer.Length-$count,$cts.Token).GetAwaiter().GetResult() if (-not $read) { break }; $count += $read } $value = $null if ($count -le 65536) { try { $value = ConvertFrom-YinkJson ([Text.Encoding]::UTF8.GetString($buffer,0,$count)) } catch {} } if ($status -lt 200 -or $status -ge 300 -or $value -isnot [System.Collections.IDictionary]) { $message = if (($status -lt 200 -or $status -ge 300) -and $value -and $value.error -is [string]) { $value.error } else { 'Send unavailable. Try again.' } throw [Exception]::new($message) } return $value } catch { $message = if ($status) { $_.Exception.Message } else { 'Send unavailable. Try again.' } $err = [Exception]::new($message); if ($status) { $err.Data['http-status'] = $status }; throw $err } finally { if ($response) { $response.Dispose() }; if ($request) { $request.Dispose() }; $cts.Dispose(); $client.Dispose() } } function Stop-YinkRemote { param($ErrorObject, $Overrides = @{}) $status = $ErrorObject.Data['http-status'] $codes = @{ 400='send-rejected'; 403='send-forbidden'; 404='send-unavailable'; 405='send-rejected'; 409='send-conflict'; 410='send-exhausted'; 429='send-busy'; 503='send-full' } $code = if ($status -and $Overrides.ContainsKey($status)) { $Overrides[$status] } elseif ($status -and $codes.ContainsKey($status)) { $codes[$status] } else { 'send-network' } $message = Get-YinkSafeText $ErrorObject.Message; if ($message.Length -gt 200) { $message = $message.Substring(0,200) } Stop-Yink $code $message 'remote' $(if ($status) { @{status=$status} }) } function Get-YinkPolicyText { param($Policy) $lines = [Collections.Generic.List[string]]::new() $lines.Add("Expires $([DateTimeOffset]::FromUnixTimeMilliseconds($Policy.expiresAt).UtcDateTime.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')).") $lines.Add($(if ($null -eq $Policy.maxReads) { 'No retrieval cap.' } else { "Maximum $($Policy.maxReads) ciphertext retrieval$(if ($Policy.maxReads -ne 1) {'s'})." })) if ($Policy.Contains('reads')) { $lines.Add("This was retrieval $($Policy.reads)$(if ($null -ne $Policy.maxReads) { ' of ' + $Policy.maxReads }).") } $lines.Add("Recipient deletion $(if ($Policy.recipientDelete) {'enabled'} else {'disabled'}).") $lines.Add($(if ($Policy.passphrase) { 'Passphrase required; deliver it separately from the link.' } else { 'No passphrase.' })) $lines -join ' ' } function Invoke-YinkSend { param($Options) $origin = Get-YinkOrigin $(if ($Options.origin) { $Options.origin } else { 'https://yink.io' }) $title = '' if ($Options.ContainsKey('title-env')) { $title = Get-YinkEnv $Options['title-env'] } elseif ($Options.ContainsKey('title-file')) { $title = (Read-YinkText $Options['title-file'] 'the title file') -replace '\r?\n$','' } if ($title.Length -gt 256) { Stop-Yink 'title-too-long' 'the title can be at most 256 characters' } if ($title -match '[\x00-\x1f\x7f]') { Stop-Yink 'bad-title' 'the title cannot contain line breaks or control characters' } $fields = @( foreach ($spec in $Options.fields) { if ($spec.label.Length -gt 128) { Stop-Yink 'label-too-long' 'a field label can be at most 128 characters' } if ($spec.label -match '[\x00-\x1f\x7f]') { Stop-Yink 'bad-label' 'a field label cannot contain line breaks or control characters' } $value = if ($spec.kind -eq 'env') { Get-YinkEnv $spec.source } else { Read-YinkText $spec.source "the file for field `"$(Get-YinkSafeText $spec.label)`"" } if ($value -ceq '') { Stop-Yink 'empty-field' "field `"$(Get-YinkSafeText $spec.label)`" is empty; every field needs a value" } [ordered]@{ label=$spec.label; value=$value } } ) $text = '' if ($Options.ContainsKey('text-file')) { $text = Read-YinkText $Options['text-file'] 'the text file' } elseif (-not $Options.ContainsKey('title-file') -and -not $Options.ContainsKey('title-env') -and -not $fields.Count -and $Options['passphrase-file'] -ne '-' -and [Console]::IsInputRedirected) { $text = Read-YinkText '-' 'stdin' } if (-not $title -and -not $text -and -not $fields.Count) { Stop-Yink 'empty-send' 'nothing to send: pipe text in, or use --text-file, --title-file/--title-env, --field-file or --field-env' } $passphrase = Get-YinkPassphrase $Options -Confirm; if ($null -eq $passphrase) { $passphrase = '' } if ($fields.Count -gt 8) { Stop-Yink 'too-many-fields' 'a send holds at most 8 fields' } $content = [ordered]@{ title=$title; text=$text; fields=$fields }; $recipientDelete = -not $Options['no-recipient-delete'] try { $prepared = New-YinkSend $content $Options.ttl $Options.maxReads $recipientDelete $passphrase } catch { if ($_.Exception.Message.Contains('24 KiB')) { Stop-Yink 'send-too-large' 'the title, labels and values must fit in 24 KiB of encoded text' }; Stop-Yink 'send-invalid' (Get-YinkSafeText $_.Exception.Message) } $parsed = (Get-YinkLink "$origin/send#$($prepared.recipientFragment)" 'recipient').link $roundTrip = Open-YinkSend $prepared.envelope $parsed $passphrase $actual = [ordered]@{ title=$roundTrip.title; text=$roundTrip.text; fields=$roundTrip.fields } if ((ConvertTo-YinkJson $actual) -cne (ConvertTo-YinkJson $content)) { Stop-Yink 'send-self-check' 'the encrypted send did not round-trip; nothing was uploaded' 'local' } $management = "$origin/send#$($prepared.senderFragment)"; $result = $null for ($attempt=1; $attempt -le 3; $attempt++) { try { $result = Invoke-YinkRequest $origin $prepared.envelope.id 'PUT' $prepared.revoke $prepared.envelope '' $(if ($Options.timeout) {$Options.timeout} else {30000}); break } catch { $status = $_.Exception.Data['http-status'] if ($status -in @(400,403,405,429,503)) { Stop-YinkRemote $_.Exception } if ($status -ne 409 -and $attempt -lt 3) { Start-Sleep -Milliseconds ($attempt*1000); continue } $details = @{id=$prepared.envelope.id; managementLink=$management}; if ($status) { $details.status=$status } Stop-Yink 'send-uncertain' "upload outcome unknown; the send may have been stored. To make sure it is gone, run: yink revoke $management" 'remote' $details } } if (-not (Test-YinkInteger $result.expiresAt 0 253402300799999)) { Stop-Yink 'send-uncertain' "the server's reply was malformed; the send may have been stored. To make sure it is gone, run: yink revoke $management" 'remote' @{id=$prepared.envelope.id;managementLink=$management} } $link = "$origin/send#$($prepared.recipientFragment)" $policy = [ordered]@{expiresAt=$result.expiresAt;maxReads=$Options.maxReads;passphrase=[bool]$passphrase;recipientDelete=$recipientDelete} $event = [ordered]@{id=$prepared.envelope.id;link=$link;managementLink=$management;ttl=$Options.ttl}; foreach ($key in $policy.Keys) { $event[$key]=$policy[$key] } Write-YinkEvent 'created' $event $link Write-YinkInfo "Management link (private; revokes this send): $management"; Write-YinkInfo (Get-YinkPolicyText $policy) } function Get-YinkRenderedContent { param($Content, [bool]$Tty) $hasText = $Content.text -is [string] -and (Test-YinkVisibleContent $Content.text) $items = [Collections.Generic.List[object]]::new() if ($hasText) { $items.Add(@{label='text';value=$Content.text;bare=$false}) } for ($i=0;$i -lt $Content.fields.Count;$i++) { $field = $Content.fields[$i] if (-not $field.label -and $field.value -ceq '') { continue } $items.Add(@{label=$(if ($field.label) {$field.label} else {"field $($i+1)"});value=$field.value;bare=-not $field.label}) } $protect = { param($v) if ($Tty) { Get-YinkSafeText $v -KeepLines } else { $v } } if (-not $Content.title -and $items.Count -eq 1 -and ($items[0].bare -or $hasText)) { return & $protect $items[0].value } $lines = [Collections.Generic.List[string]]::new() if ($Content.title) { $lines.Add("title: $(Get-YinkSafeText $Content.title)") } foreach ($item in $items) { $label = Get-YinkSafeText $item.label; $value = & $protect $item.value; $lines.Add("${label}:$(if ($item.value.Contains("`n")) {"`n"} else {' '})$value") } $lines -join "`n" } function Invoke-YinkReceive { param($Options) $parsed = Get-YinkLink $Options.link 'recipient'; $link = $parsed.link $origin = Get-YinkOrigin $(if ($Options.origin) { $Options.origin } else { $parsed.origin }) $interactive = -not [Console]::IsInputRedirected -and -not $Options.ContainsKey('passphrase-env') -and -not $Options.ContainsKey('passphrase-file') $passphrase = Get-YinkPassphrase $Options if (-not $link.passphrase -and $null -ne $passphrase) { Stop-Yink 'passphrase-unexpected' 'this link says the send has no passphrase' } if ($link.passphrase -and $null -eq $passphrase) { if (-not $interactive) { Stop-Yink 'passphrase-required' 'this send needs a passphrase: use --passphrase-env, --passphrase-file or --passphrase-prompt' } $passphrase = Assert-YinkPassphrase (Read-YinkHidden 'Passphrase: ') } $timeout = if ($Options.timeout) {$Options.timeout} else {30000} try { $delivery = Invoke-YinkRequest $origin $link.id 'GET' $link.read -Timeout $timeout } catch { $status = $_.Exception.Data['http-status'] if ($status -eq 404) { Stop-Yink 'send-unavailable' 'send unavailable: expired, deleted, or unknown' 'remote' @{status=404} } if ($status -eq 410) { Stop-Yink 'send-exhausted' 'the retrieval cap has been reached; this send can no longer be opened' 'remote' @{status=410} } Stop-YinkRemote $_.Exception } if (-not $delivery.envelope -or -not (Test-YinkInteger $delivery.expiresAt 0 253402300799999) -or -not (Test-YinkInteger $delivery.reads)) { Stop-Yink 'send-network' "the server's reply was malformed" 'remote' } for ($attempt=1;$attempt -le 3;$attempt++) { try { if ($delivery.envelope.passphrase -and -not $passphrase) { if (-not $interactive) { Stop-Yink 'passphrase-required' 'this send needs a passphrase (its link said otherwise); this retrieval was counted' } $passphrase = Assert-YinkPassphrase (Read-YinkHidden 'Passphrase: ') } $content = Open-YinkSend $delivery.envelope $link $(if ($delivery.envelope.passphrase) {$passphrase} else {''}); break } catch { if ($_.Exception.Data['yink-code']) { throw } if ($interactive -and $delivery.envelope.passphrase -and $attempt -lt 3) { [Console]::Error.WriteLine('Could not unlock. Check the passphrase.'); $passphrase = Assert-YinkPassphrase (Read-YinkHidden 'Passphrase: '); continue } Stop-Yink 'unlock-failed' "$(Get-YinkSafeText $_.Exception.Message) This retrieval was counted." 'transfer' } } $policy = [ordered]@{expiresAt=$delivery.expiresAt;maxReads=$delivery.envelope.maxReads;reads=$delivery.reads;passphrase=$delivery.envelope.passphrase;recipientDelete=[bool]$content.recipientToken} $event = [ordered]@{id=$link.id;title=$content.title;text=$content.text;fields=$content.fields}; foreach ($key in $policy.Keys) {$event[$key]=$policy[$key]} Write-YinkEvent 'received' $event (Get-YinkRenderedContent $content (-not [Console]::IsOutputRedirected)) -Exact:([Console]::IsOutputRedirected) Write-YinkInfo (Get-YinkPolicyText $policy) if ($null -ne $policy.maxReads -and $policy.reads -ge $policy.maxReads) { Write-YinkInfo 'That was the final allowed retrieval; the stored ciphertext is gone.' } if ($Options.delete) { if (-not $content.recipientToken) { Stop-Yink 'delete-disabled' 'the sender disabled recipient deletion; only the sender can revoke this send' 'remote' } try { $null = Invoke-YinkRequest $origin $link.id 'DELETE' $content.recipientToken -Action 'recipient' -Timeout $timeout } catch { Stop-YinkRemote $_.Exception @{404='delete-unavailable'} } Write-YinkEvent 'deleted' @{id=$link.id;by='recipient'} Write-YinkInfo 'Send deleted for everyone. Future retrievals are unavailable; copies already saved cannot be erased.' } } function Invoke-YinkRevoke { param($Options) $parsed = Get-YinkLink $Options.link 'management'; $origin = Get-YinkOrigin $(if ($Options.origin) {$Options.origin} else {$parsed.origin}) try { $null = Invoke-YinkRequest $origin $parsed.link.id 'DELETE' $parsed.link.revoke -Action 'revoke' -Timeout $(if ($Options.timeout) {$Options.timeout} else {30000}) } catch { if ($_.Exception.Data['http-status'] -eq 404) { Stop-Yink 'send-unavailable' 'send unavailable: already deleted, expired, or this revoke link is invalid' 'remote' @{status=404} } Stop-YinkRemote $_.Exception } Write-YinkEvent 'revoked' @{id=$parsed.link.id} 'Send revoked. Future retrievals are unavailable; copies already saved cannot be erased.' } function Get-YinkInstallAttributes { param([string]$Path) try { return [IO.File]::GetAttributes($Path) } catch { $cause = if ($_.Exception.InnerException) { $_.Exception.InnerException } else { $_.Exception } if ($cause -is [IO.FileNotFoundException] -or $cause -is [IO.DirectoryNotFoundException]) { return $null } throw } } function Assert-YinkInstallDirectory { param([string]$Path) $attributes = Get-YinkInstallAttributes $Path if ($null -ne $attributes -and (($attributes -band [IO.FileAttributes]::Directory) -eq 0 -or ($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Stop-Yink 'install-collision' "refusing unsafe install directory: $(Get-YinkSafeText $Path)" 'local' } } function Assert-YinkInstallFile { param([string]$Path) $attributes = Get-YinkInstallAttributes $Path if ($null -ne $attributes -and (($attributes -band [IO.FileAttributes]::Directory) -ne 0 -or ($attributes -band [IO.FileAttributes]::ReparsePoint) -ne 0)) { Stop-Yink 'install-collision' "refusing non-regular install file: $(Get-YinkSafeText $Path)" 'local' } return $null -ne $attributes } function Get-YinkInstallHash { param([byte[]]$Bytes) $sha = [Security.Cryptography.SHA256]::Create() try { return [BitConverter]::ToString($sha.ComputeHash($Bytes)).Replace('-','').ToLowerInvariant() } finally { $sha.Dispose() } } function Set-YinkAtomicInstallFile { param([string]$Path,[byte[]]$Bytes,[switch]$Executable) $exists = Assert-YinkInstallFile $Path $expected = Get-YinkInstallHash $Bytes if ($exists -and (Get-FileHash -LiteralPath $Path -Algorithm SHA256).Hash.ToLowerInvariant() -ceq $expected) { if ($Executable) { & chmod 755 -- $Path; if ($LASTEXITCODE -ne 0) { throw 'Could not mark the yink launcher executable.' } } return } $parent = [IO.Path]::GetDirectoryName($Path) $temp = Join-Path $parent ('.' + [IO.Path]::GetFileName($Path) + '.' + [Guid]::NewGuid().ToString('N') + '.tmp') try { $stream = [IO.File]::Open($temp,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None) try { $stream.Write($Bytes,0,$Bytes.Length); $stream.Flush($true) } finally { $stream.Dispose() } $null = Assert-YinkInstallFile $Path [IO.File]::Move($temp,$Path,$true) if ($Executable) { & chmod 755 -- $Path; if ($LASTEXITCODE -ne 0) { throw 'Could not mark the yink launcher executable.' } } } finally { if ([IO.File]::Exists($temp)) { [IO.File]::Delete($temp) } } } function Install-YinkLocal { param([string]$Directory, [string]$SourcePath = $script:YinkScriptPath) if (-not $Directory) { $Directory = if ($IsWindows) { Join-Path ([Environment]::GetFolderPath('LocalApplicationData')) 'yink/bin' } else { Join-Path ([Environment]::GetFolderPath('UserProfile')) '.local/bin' } } if ($Directory -match '[\x00-\x1f\x7f-\x9f\u061c\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff\u00ad]' -or $Directory.IndexOf([IO.Path]::PathSeparator) -ge 0) { Stop-Yink 'bad-install-directory' 'the install directory cannot contain control characters, directionality controls or the PATH separator' } try { $Directory = [IO.Path]::GetFullPath($Directory) # Read the already-downloaded trusted source BEFORE writing anything. # No fetch, update, elevation, PATH/profile or execution-policy change. $sourceFullPath = [IO.Path]::GetFullPath($SourcePath) $bytes = [IO.File]::ReadAllBytes($sourceFullPath) $library = Join-Path $Directory 'lib' Assert-YinkInstallDirectory $Directory $null = [IO.Directory]::CreateDirectory($library) Assert-YinkInstallDirectory $Directory Assert-YinkInstallDirectory $library $target = Join-Path $library 'yink.ps1' $comparison = if ($IsWindows) { [StringComparison]::OrdinalIgnoreCase } else { [StringComparison]::Ordinal } $utf8 = [Text.UTF8Encoding]::new($false) $managed = [ordered]@{'lib/yink.ps1'=@{path=$target;bytes=$bytes;executable=$false}} if ($IsWindows) { # PS resolves .ps1 before .cmd. Preserve pipes, console prompts and # bearer fragment ampersands by passing argv directly to pwsh.exe. $managed['yink.ps1'] = @{path=(Join-Path $Directory 'yink.ps1');bytes=$utf8.GetBytes(@' #requires -Version 7.0 $scriptPath = Join-Path $PSScriptRoot 'lib/yink.ps1' if ($MyInvocation.ExpectingInput) { $input | & pwsh.exe -NoLogo -NoProfile -File $scriptPath @args } else { & pwsh.exe -NoLogo -NoProfile -File $scriptPath @args } exit $LASTEXITCODE '@ + "`n");executable=$false} $managed['yink.cmd'] = @{path=(Join-Path $Directory 'yink.cmd');bytes=$utf8.GetBytes("@echo off`r`npwsh.exe -NoLogo -NoProfile -File `"%~dp0lib\yink.ps1`" %*`r`nexit /b %errorlevel%`r`n");executable=$false} } else { $launcher = Join-Path $Directory 'yink' $managed['yink'] = @{path=$launcher;bytes=$utf8.GetBytes(@' #!/bin/sh exec pwsh -NoLogo -NoProfile -File "$(dirname "$0")/lib/yink.ps1" "$@" '@ + "`n");executable=$true} } $marker = Join-Path $Directory '.yink-powershell-install.json' $markerExists = Assert-YinkInstallFile $marker if ($markerExists) { try { $old = [IO.File]::ReadAllText($marker,$utf8) | ConvertFrom-Json -AsHashtable if ($old.owner -cne 'yink-powershell-cli' -or $old.directory -cne $Directory -or -not ($old.files -is [Collections.IDictionary])) { throw 'ownership' } } catch { Stop-Yink 'install-collision' 'existing install marker is not owned by this yink installation' 'local' } foreach ($name in $managed.Keys) { if (-not $old.files.Contains($name) -or $old.files[$name] -notmatch '^[0-9a-f]{64}$') { Stop-Yink 'install-collision' 'existing install marker is incomplete' 'local' } $path = $managed[$name].path if ((Assert-YinkInstallFile $path) -and (Get-FileHash -LiteralPath $path -Algorithm SHA256).Hash.ToLowerInvariant() -cne $old.files[$name]) { Stop-Yink 'install-collision' "refusing to replace a changed or unmanaged file: $(Get-YinkSafeText $path)" 'local' } } } else { $present = @($managed.Keys | Where-Object { Assert-YinkInstallFile $managed[$_].path }) if ($present.Count -gt 0) { $legacyRuntimeHashes = @('32123650b7ba80274f7a67620fd91ccbdaafc02cb910ed3871961363c89dac35') if ($present.Count -ne $managed.Count) { Stop-Yink 'install-collision' 'refusing to replace a partial unmanaged installation' 'local' } foreach ($name in $managed.Keys) { $entry = $managed[$name] $actual = (Get-FileHash -LiteralPath $entry.path -Algorithm SHA256).Hash.ToLowerInvariant() $expected = Get-YinkInstallHash $entry.bytes if ($actual -cne $expected -and ($name -cne 'lib/yink.ps1' -or $legacyRuntimeHashes -cnotcontains $actual)) { Stop-Yink 'install-collision' "refusing to replace an unmanaged file: $(Get-YinkSafeText $entry.path)" 'local' } } } } foreach ($name in $managed.Keys) { $entry = $managed[$name] if ([string]::Equals($sourceFullPath,$entry.path,$comparison) -and $name -ceq 'lib/yink.ps1') { continue } Set-YinkAtomicInstallFile $entry.path $entry.bytes -Executable:([bool]$entry.executable) } $fileHashes = [ordered]@{} foreach ($name in $managed.Keys) { $fileHashes[$name] = Get-YinkInstallHash $managed[$name].bytes } $metadata = [ordered]@{owner='yink-powershell-cli';directory=$Directory;files=$fileHashes} Set-YinkAtomicInstallFile $marker $utf8.GetBytes((ConvertTo-Json $metadata -Compress -Depth 4) + "`n") # Single quotes (doubled when embedded) prevent interpolation or command # substitution in this copyable PowerShell command, even for odd paths. $quoted = "'" + $Directory.Replace("'","''") + "'" return [ordered]@{directory=$Directory;script=$target;command='yink';pathCommand=(' $env:PATH = ' + $quoted + ' + [IO.Path]::PathSeparator + $env:PATH').TrimStart()} } catch { if ($_.Exception.Data['yink-code']) { throw } Stop-Yink 'install-failed' 'could not install this local yink script; choose a writable per-user --directory' 'local' } } function Invoke-YinkInstall { param($Options) $result = Install-YinkLocal $Options.directory Write-YinkEvent 'installed' $result ("Installed yink in $(Get-YinkSafeText $result.directory).`nFor this PowerShell session, run exactly:`n$($result.pathCommand)`nyink --help`nFor future sessions, add this directory to your user PATH or put that PATH command in your PowerShell profile.") } function Invoke-YinkMain { param([string[]]$Argv) $ErrorActionPreference = 'Stop' $script:YinkOut = [Console]::OpenStandardOutput(); $script:YinkJson = $Argv -ccontains '--json' try { $options = Get-YinkArgs $Argv; $script:YinkJson = [bool]$options.json if ($options.command -eq 'help' -or $options.help) { Write-YinkRaw ($script:YinkUsage + "`n"); return 0 } if ($options.command -eq 'install') { Invoke-YinkInstall $options; return 0 } Initialize-YinkCrypto switch ($options.command) { send { Invoke-YinkSend $options } receive { Invoke-YinkReceive $options } revoke { Invoke-YinkRevoke $options } default { Stop-Yink "$($options.command)-unavailable" "$($options.command) is not implemented in the native PowerShell port; use the npm CLI for this command" 'local' @{command=$options.command} } } return 0 } catch { $err = $_.Exception $code = if ($err.Data['yink-code']) {$err.Data['yink-code']} else {'local-failure'} $class = if ($err.Data['yink-class']) {$err.Data['yink-class']} else {'local'} $message = if ($err.Data['yink-code']) {$err.Message} else {'native CLI failed locally'} $fields = [ordered]@{code=$code;class=$class;message=$message}; if ($err.Data['yink-details']) {$fields.details=$err.Data['yink-details']} if ($script:YinkJson) { Write-YinkEvent 'error' $fields } else { [Console]::Error.WriteLine("error [${code}]: $(Get-YinkSafeText $message)") } return @{usage=2;remote=3;transfer=4;local=5}[$class] } finally { $script:YinkOut.Flush() } } if ($MyInvocation.InvocationName -ne '.') { $ErrorActionPreference = 'Stop' exit (Invoke-YinkMain @($args)) } else { Initialize-YinkCrypto }