Skip to content

Commit 395ed0b

Browse files
authored
Merge pull request #636 from KelvinTegelaar/dev
[pull] dev from KelvinTegelaar:dev
2 parents e37b4ed + c0457be commit 395ed0b

File tree

3 files changed

+455
-1
lines changed

3 files changed

+455
-1
lines changed
Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
function New-CIPPAzServiceSAS {
2+
[CmdletBinding()] param(
3+
[Parameter(Mandatory = $true)] [string] $AccountName,
4+
[Parameter(Mandatory = $true)] [string] $AccountKey,
5+
[Parameter(Mandatory = $true)] [ValidateSet('blob', 'queue', 'file', 'table')] [string] $Service,
6+
[Parameter(Mandatory = $true)] [string] $ResourcePath,
7+
[Parameter(Mandatory = $true)] [string] $Permissions,
8+
[Parameter(Mandatory = $false)] [DateTime] $StartTime,
9+
[Parameter(Mandatory = $true)] [DateTime] $ExpiryTime,
10+
[Parameter(Mandatory = $false)] [ValidateSet('http', 'https', 'https,http')] [string] $Protocol = 'https',
11+
[Parameter(Mandatory = $false)] [string] $IP,
12+
[Parameter(Mandatory = $false)] [string] $SignedIdentifier,
13+
[Parameter(Mandatory = $false)] [string] $Version = '2022-11-02',
14+
[Parameter(Mandatory = $false)] [ValidateSet('b', 'c', 'd', 'bv', 'bs', 'f', 's')] [string] $SignedResource,
15+
[Parameter(Mandatory = $false)] [int] $DirectoryDepth,
16+
[Parameter(Mandatory = $false)] [string] $SnapshotTime,
17+
# Optional response header overrides (Blob/Files)
18+
[Parameter(Mandatory = $false)] [string] $CacheControl,
19+
[Parameter(Mandatory = $false)] [string] $ContentDisposition,
20+
[Parameter(Mandatory = $false)] [string] $ContentEncoding,
21+
[Parameter(Mandatory = $false)] [string] $ContentLanguage,
22+
[Parameter(Mandatory = $false)] [string] $ContentType,
23+
# Optional encryption scope (Blob, 2020-12-06+)
24+
[Parameter(Mandatory = $false)] [string] $EncryptionScope,
25+
# Optional connection string for endpoint/emulator support
26+
[Parameter(Mandatory = $false)] [string] $ConnectionString = $env:AzureWebJobsStorage
27+
)
28+
29+
# Local helpers: canonicalized resource and signature (standalone)
30+
function _GetCanonicalizedResource {
31+
param(
32+
[Parameter(Mandatory = $true)][string] $AccountName,
33+
[Parameter(Mandatory = $true)][ValidateSet('blob', 'queue', 'file', 'table')] [string] $Service,
34+
[Parameter(Mandatory = $true)][string] $ResourcePath
35+
)
36+
$decodedPath = [System.Web.HttpUtility]::UrlDecode(($ResourcePath.TrimStart('/')))
37+
switch ($Service) {
38+
'blob' { return "/blob/$AccountName/$decodedPath" }
39+
'queue' { return "/queue/$AccountName/$decodedPath" }
40+
'file' { return "/file/$AccountName/$decodedPath" }
41+
'table' { return "/table/$AccountName/$decodedPath" }
42+
}
43+
}
44+
45+
function _NewSharedKeySignature {
46+
param(
47+
[Parameter(Mandatory = $true)][string] $AccountKey,
48+
[Parameter(Mandatory = $true)][string] $StringToSign
49+
)
50+
$keyBytes = [Convert]::FromBase64String($AccountKey)
51+
$hmac = [System.Security.Cryptography.HMACSHA256]::new($keyBytes)
52+
try {
53+
$bytes = [System.Text.Encoding]::UTF8.GetBytes($StringToSign)
54+
$sig = $hmac.ComputeHash($bytes)
55+
return [Convert]::ToBase64String($sig)
56+
} finally { $hmac.Dispose() }
57+
}
58+
59+
# Parse connection string for emulator/provided endpoints
60+
$ProvidedEndpoint = $null
61+
$ProvidedPath = $null
62+
$EmulatorHost = $null
63+
$EndpointSuffix = 'core.windows.net'
64+
65+
if ($ConnectionString) {
66+
$conn = @{}
67+
foreach ($part in ($ConnectionString -split ';')) {
68+
$p = $part.Trim()
69+
if ($p -and $p -match '^(.+?)=(.+)$') { $conn[$matches[1]] = $matches[2] }
70+
}
71+
if ($conn['EndpointSuffix']) { $EndpointSuffix = $conn['EndpointSuffix'] }
72+
73+
$ServiceCapitalized = [char]::ToUpper($Service[0]) + $Service.Substring(1)
74+
$EndpointKey = "${ServiceCapitalized}Endpoint"
75+
if ($conn[$EndpointKey]) {
76+
$ProvidedEndpoint = $conn[$EndpointKey]
77+
$ep = [Uri]::new($ProvidedEndpoint)
78+
$Protocol = $ep.Scheme
79+
$EmulatorHost = $ep.Host
80+
if ($ep.Port -ne -1) { $EmulatorHost = "$($ep.Host):$($ep.Port)" }
81+
$ProvidedPath = $ep.AbsolutePath.TrimEnd('/')
82+
} elseif ($conn['UseDevelopmentStorage'] -eq 'true') {
83+
# Emulator defaults
84+
if (-not $AccountName) { $AccountName = 'devstoreaccount1' }
85+
if (-not $AccountKey) { $AccountKey = 'Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==' }
86+
$Protocol = 'http'
87+
$ports = @{ blob = 10000; queue = 10001; table = 10002 }
88+
$EmulatorHost = "127.0.0.1:$($ports[$Service])"
89+
}
90+
}
91+
92+
# Build the resource URI
93+
if ($ResourcePath.StartsWith('/')) { $ResourcePath = $ResourcePath.TrimStart('/') }
94+
$UriBuilder = [System.UriBuilder]::new()
95+
$UriBuilder.Scheme = $Protocol
96+
97+
if ($ProvidedEndpoint) {
98+
# Use provided endpoint + its base path
99+
if ($EmulatorHost -match '^(.+?):(\d+)$') { $UriBuilder.Host = $matches[1]; $UriBuilder.Port = [int]$matches[2] }
100+
else { $UriBuilder.Host = $EmulatorHost }
101+
$UriBuilder.Path = ("$ProvidedPath/$ResourcePath").Replace('//', '/')
102+
} elseif ($EmulatorHost) {
103+
# Emulator: include account name in path
104+
if ($EmulatorHost -match '^(.+?):(\d+)$') { $UriBuilder.Host = $matches[1]; $UriBuilder.Port = [int]$matches[2] }
105+
else { $UriBuilder.Host = $EmulatorHost }
106+
$UriBuilder.Path = "$AccountName/$ResourcePath"
107+
} else {
108+
# Standard Azure endpoint
109+
$UriBuilder.Host = "$AccountName.$Service.$EndpointSuffix"
110+
$UriBuilder.Path = $ResourcePath
111+
}
112+
$uri = $UriBuilder.Uri
113+
114+
# Canonicalized resource for SAS string-to-sign (service-name style, 2015-02-21+)
115+
$canonicalizedResource = _GetCanonicalizedResource -AccountName $AccountName -Service $Service -ResourcePath $ResourcePath
116+
117+
# Time formatting per SAS spec (ISO 8601 UTC)
118+
function _FormatSasTime($dt) {
119+
if ($null -eq $dt) { return '' }
120+
if ($dt -is [string]) {
121+
if ([string]::IsNullOrWhiteSpace($dt)) { return '' }
122+
$parsed = [DateTime]::Parse($dt, [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AssumeUniversal)
123+
$utc = $parsed.ToUniversalTime()
124+
return $utc.ToString('yyyy-MM-ddTHH:mm:ssZ')
125+
}
126+
if ($dt -is [DateTime]) {
127+
$utc = ([DateTime]$dt).ToUniversalTime()
128+
return $utc.ToString('yyyy-MM-ddTHH:mm:ssZ')
129+
}
130+
return ''
131+
}
132+
133+
$st = _FormatSasTime $StartTime
134+
$se = _FormatSasTime $ExpiryTime
135+
if ([string]::IsNullOrEmpty($se)) { throw 'ExpiryTime is required for SAS generation.' }
136+
137+
# Assemble query parameters (service-specific)
138+
$q = @{}
139+
$q['sp'] = $Permissions
140+
if ($st) { $q['st'] = $st }
141+
$q['se'] = $se
142+
if ($IP) { $q['sip'] = $IP }
143+
if ($Protocol) { $q['spr'] = $Protocol }
144+
if ($Version) { $q['sv'] = $Version }
145+
if ($SignedIdentifier) { $q['si'] = $SignedIdentifier }
146+
147+
# Blob/Files response headers overrides
148+
if ($CacheControl) { $q['rscc'] = $CacheControl }
149+
if ($ContentDisposition) { $q['rscd'] = $ContentDisposition }
150+
if ($ContentEncoding) { $q['rsce'] = $ContentEncoding }
151+
if ($ContentLanguage) { $q['rscl'] = $ContentLanguage }
152+
if ($ContentType) { $q['rsct'] = $ContentType }
153+
154+
# Resource-type specifics
155+
$includeEncryptionScope = $false
156+
if ($Service -eq 'blob') {
157+
if (-not $SignedResource) { throw 'SignedResource (sr) is required for blob SAS: use b, c, d, bv, or bs.' }
158+
$q['sr'] = $SignedResource
159+
# Blob snapshot time uses the 'snapshot' parameter when applicable
160+
if ($SnapshotTime) { $q['snapshot'] = $SnapshotTime }
161+
if ($SignedResource -eq 'd') {
162+
if ($null -eq $DirectoryDepth) { throw 'DirectoryDepth (sdd) is required when sr=d (Data Lake Hierarchical Namespace).' }
163+
$q['sdd'] = [string]$DirectoryDepth
164+
}
165+
if ($EncryptionScope -and $Version -ge '2020-12-06') {
166+
$q['ses'] = $EncryptionScope
167+
$includeEncryptionScope = $true
168+
}
169+
} elseif ($Service -eq 'file') {
170+
if (-not $SignedResource) { throw 'SignedResource (sr) is required for file SAS: use f or s.' }
171+
$q['sr'] = $SignedResource
172+
if ($SnapshotTime) { $q['sst'] = $SnapshotTime }
173+
} elseif ($Service -eq 'table') {
174+
# Table SAS may include ranges (spk/srk/epk/erk), omitted here unless future parameters are added
175+
# Table also uses tn (table name) in query, but canonicalizedResource already includes table name
176+
# We rely on canonicalizedResource and omit tn unless specified by callers via ResourcePath
177+
} elseif ($Service -eq 'queue') {
178+
# No sr for queue
179+
}
180+
181+
# Construct string-to-sign based on service and version
182+
$StringToSign = $null
183+
if ($Service -eq 'blob') {
184+
# Version 2018-11-09 and later (optionally 2020-12-06 with encryption scope)
185+
$fields = @(
186+
$q['sp'],
187+
($st ?? ''),
188+
$q['se'],
189+
$canonicalizedResource,
190+
($q.ContainsKey('si') ? $q['si'] : ''),
191+
($q.ContainsKey('sip') ? $q['sip'] : ''),
192+
($q.ContainsKey('spr') ? $q['spr'] : ''),
193+
($q.ContainsKey('sv') ? $q['sv'] : ''),
194+
$q['sr'],
195+
($q.ContainsKey('snapshot') ? $q['snapshot'] : ''),
196+
($includeEncryptionScope ? $q['ses'] : ''),
197+
($q.ContainsKey('rscc') ? $q['rscc'] : ''),
198+
($q.ContainsKey('rscd') ? $q['rscd'] : ''),
199+
($q.ContainsKey('rsce') ? $q['rsce'] : ''),
200+
($q.ContainsKey('rscl') ? $q['rscl'] : ''),
201+
($q.ContainsKey('rsct') ? $q['rsct'] : '')
202+
)
203+
$StringToSign = ($fields -join "`n")
204+
} elseif ($Service -eq 'file') {
205+
# Use 2015-04-05+ format (no signedResource in string until 2018-11-09; we include response headers)
206+
$fields = @(
207+
$q['sp'],
208+
($st ?? ''),
209+
$q['se'],
210+
$canonicalizedResource,
211+
($q.ContainsKey('si') ? $q['si'] : ''),
212+
($q.ContainsKey('sip') ? $q['sip'] : ''),
213+
($q.ContainsKey('spr') ? $q['spr'] : ''),
214+
($q.ContainsKey('sv') ? $q['sv'] : ''),
215+
($q.ContainsKey('rscc') ? $q['rscc'] : ''),
216+
($q.ContainsKey('rscd') ? $q['rscd'] : ''),
217+
($q.ContainsKey('rsce') ? $q['rsce'] : ''),
218+
($q.ContainsKey('rscl') ? $q['rscl'] : ''),
219+
($q.ContainsKey('rsct') ? $q['rsct'] : '')
220+
)
221+
$StringToSign = ($fields -join "`n")
222+
} elseif ($Service -eq 'queue') {
223+
# Version 2015-04-05 and later
224+
$fields = @(
225+
$q['sp'],
226+
($st ?? ''),
227+
$q['se'],
228+
$canonicalizedResource,
229+
($q.ContainsKey('si') ? $q['si'] : ''),
230+
($q.ContainsKey('sip') ? $q['sip'] : ''),
231+
($q.ContainsKey('spr') ? $q['spr'] : ''),
232+
($q.ContainsKey('sv') ? $q['sv'] : '')
233+
)
234+
$StringToSign = ($fields -join "`n")
235+
} elseif ($Service -eq 'table') {
236+
# Version 2015-04-05 and later
237+
$fields = @(
238+
$q['sp'],
239+
($st ?? ''),
240+
$q['se'],
241+
$canonicalizedResource,
242+
($q.ContainsKey('si') ? $q['si'] : ''),
243+
($q.ContainsKey('sip') ? $q['sip'] : ''),
244+
($q.ContainsKey('spr') ? $q['spr'] : ''),
245+
($q.ContainsKey('sv') ? $q['sv'] : ''),
246+
'', # startingPartitionKey
247+
'', # startingRowKey
248+
'', # endingPartitionKey
249+
'' # endingRowKey
250+
)
251+
$StringToSign = ($fields -join "`n")
252+
}
253+
254+
# Generate signature using account key (HMAC-SHA256 over UTF-8 string-to-sign)
255+
try {
256+
$SignatureBase64 = _NewSharedKeySignature -AccountKey $AccountKey -StringToSign $StringToSign
257+
} catch {
258+
throw "Failed to create SAS signature: $($_.Exception.Message)"
259+
}
260+
261+
# Store signature; will be URL-encoded when assembling query
262+
$q['sig'] = $SignatureBase64
263+
264+
# Compose ordered query for readability (common fields first)
265+
$orderedKeys = @('sp', 'st', 'se', 'sip', 'spr', 'sv', 'sr', 'si', 'snapshot', 'ses', 'sdd', 'rscc', 'rscd', 'rsce', 'rscl', 'rsct', 'sig')
266+
$parts = [System.Collections.Generic.List[string]]::new()
267+
foreach ($k in $orderedKeys) {
268+
if ($q.ContainsKey($k) -and -not [string]::IsNullOrEmpty($q[$k])) {
269+
$parts.Add("$k=" + [System.Net.WebUtility]::UrlEncode($q[$k]))
270+
}
271+
}
272+
# Include any remaining keys
273+
foreach ($k in $q.Keys) {
274+
if ($orderedKeys -notcontains $k) {
275+
$parts.Add("$k=" + [System.Net.WebUtility]::UrlEncode($q[$k]))
276+
}
277+
}
278+
279+
$token = '?' + ($parts -join '&')
280+
281+
# Return structured output for debugging/usage
282+
[PSCustomObject]@{
283+
Token = $token
284+
Query = $q
285+
CanonicalizedResource = $canonicalizedResource
286+
StringToSign = $StringToSign
287+
Version = $Version
288+
Service = $Service
289+
ResourceUri = $uri.AbsoluteUri
290+
}
291+
}

0 commit comments

Comments
 (0)