Viewing is open to everyone. Downloading and starring need a GitHub sign-in. Sign in with GitHub

SafeActionsTests.cs

v0.1.0-build.85 Latest Commit ede38f5

Size
13 KB
Lines
230
Language
csharp
SHA-256
4796b93ac250…
View on GitHub

Sign in with GitHub to download. Each download is recorded with your GitHub username.

1using System;
2using System.Collections.Generic;
3using System.IO;
4using System.Linq;
5using System.Text;
6using System.Text.Json;
7using System.Threading.Tasks;
8using OpenCKey.Interop;
9using Xunit;
10
11namespace OpenCKey.Tests
12{
13 // The core against tests/vectors/safe-actions-v1.json (an independent
14 // implementation's envelopes), then the receiving app's policy end to end
15 // over the native core with a fixed clock.
16 public class SafeActionsTests
17 {
18 private const string Key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa62325af021a68f707511a";
19 private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeSeconds(1794000000);
20
21 private static JsonElement Vectors()
22 {
23 for (var dir = new DirectoryInfo(AppContext.BaseDirectory); dir != null; dir = dir.Parent)
24 {
25 string path = Path.Combine(dir.FullName, "tests", "vectors", "safe-actions-v1.json");
26 if (File.Exists(path)) return JsonDocument.Parse(File.ReadAllText(path)).RootElement;
27 }
28 throw new FileNotFoundException("tests/vectors/safe-actions-v1.json");
29 }
30
31 private static JsonElement Vector(string name) => Vectors().GetProperty("vectors").EnumerateArray().First(v => v.GetProperty("name").GetString() == name);
32 private static string Payload(string name) => Vector(name).GetProperty("payload").GetString()!;
33
34 [Fact]
35 public void StructLayout()
36 {
37 Assert.Equal(1736, System.Runtime.InteropServices.Marshal.SizeOf<NativeSafeAction>());
38 Assert.Equal(688, (int)System.Runtime.InteropServices.Marshal.OffsetOf<NativeSafeAction>(nameof(NativeSafeAction.IssuedAt)));
39 Assert.Equal(1728, (int)System.Runtime.InteropServices.Marshal.OffsetOf<NativeSafeAction>(nameof(NativeSafeAction.ParamsLength)));
40 }
41
42 [Fact]
43 public void EveryVectorVerdict()
44 {
45 int n = 0;
46 var core = new CKeySafeActions("com.viadere").Core;
47 foreach (var v in Vectors().GetProperty("vectors").EnumerateArray())
48 {
49 byte[] payload = Encoding.UTF8.GetBytes(v.GetProperty("payload").GetString()!);
50 byte[] key = CKeySafeActions.HexKey(v.GetProperty("public_key").GetString())!;
51 int status = core(payload, key, v.GetProperty("now").GetInt64(), v.GetProperty("max_lifetime").GetInt32(), out var fields, out string message);
52 string expect = v.GetProperty("expect").GetString()!;
53 bool ok = expect switch
54 {
55 "ok" => status == 0,
56 "integrity" => status == 6,
57 "expired" => status == 7,
58 "invalid" => status == 1,
59 "invalid_or_integrity" => status == 1 || status == 6,
60 _ => false,
61 };
62 Assert.True(ok, $"{v.GetProperty("name").GetString()}: expected {expect}, got {status} {message}");
63 if (expect == "ok")
64 {
65 var f = v.GetProperty("fields");
66 Assert.Equal(f.GetProperty("issuer").GetString(), fields!.Issuer);
67 Assert.Equal(f.GetProperty("request_id").GetString(), fields.RequestId);
68 Assert.Equal(f.GetProperty("expires_at").GetInt64(), fields.ExpiresAt);
69 Assert.Equal(f.GetProperty("scope").GetInt32(), (int)fields.Scope);
70 Assert.Equal(f.GetProperty("params").GetString(), fields.ParamsText);
71 }
72 n++;
73 }
74 Assert.True(n >= 20);
75 }
76
77 private static CKeySafeActions Actions(Func<string, string?>? fetch = null)
78 {
79 var a = new CKeySafeActions("com.viadere");
80 a.Clock = () => Now;
81 a.Fetch = (url, _) => Task.FromResult(fetch?.Invoke(url));
82 return a;
83 }
84
85 [Fact]
86 public async Task SameDeviceRunsOnceWithoutPrompt()
87 {
88 var ran = new List<CKeyVerifiedSafeAction>();
89 var a = Actions().Trust("citeory.com", "2026-11", Key).Register("open.library", new[] { "citeory.com" }, x => ran.Add(x));
90 string text = Payload("valid same-device action");
91 var outcome = await a.HandleAsync(text);
92 Assert.Equal(CKeySafeActionOutcomeKind.Performed, outcome.Kind);
93 Assert.Single(ran);
94 Assert.Equal("{\"item\":\"apa-7\"}", ran[0].ParamsText);
95 var replay = await a.HandleAsync(text);
96 Assert.Equal(CKeySafeActionOutcomeKind.Rejected, replay.Kind);
97 Assert.Equal("this code has already been used", replay.Reason);
98 Assert.Single(ran);
99 }
100
101 [Fact]
102 public async Task PolicyChecksRunNothing()
103 {
104 int ran = 0;
105 string text = Payload("valid same-device action");
106 Assert.Equal(CKeySafeActionOutcomeKind.NotASafeAction, (await Actions().HandleAsync("https://citeory.com")).Kind);
107 Assert.Equal(CKeySafeActionOutcomeKind.Rejected, (await Actions().HandleAsync("ckey-action:1:AAAA")).Kind);
108 var other = new CKeySafeActions("com.other") { Clock = () => Now };
109 other.Trust("citeory.com", "2026-11", Key).Register("open.library", new[] { "citeory.com" }, _ => ran++);
110 Assert.Contains("another app", (await other.HandleAsync(text)).Reason);
111 Assert.Contains("does not accept", (await Actions().Trust("citeory.com", "2026-11", Key).HandleAsync(text)).Reason);
112 Assert.Contains("may not request", (await Actions().Register("open.library", new[] { "example.org" }, _ => ran++).HandleAsync(text)).Reason);
113 Assert.Contains("no trusted key", (await Actions().Register("open.library", new[] { "citeory.com" }, _ => ran++).HandleAsync(text)).Reason);
114 var wrongKey = Actions().Trust("citeory.com", "2026-11", "3d4017c3e843895a92b70aa74d1b7ebc9c982ccf2ec4968cc0cd55f12af4660c")
115 .Register("open.library", new[] { "citeory.com" }, _ => ran++);
116 Assert.Equal(CKeySafeActionOutcomeKind.Rejected, (await wrongKey.HandleAsync(text)).Kind);
117 var late = Actions().Trust("citeory.com", "2026-11", Key).Register("open.library", new[] { "citeory.com" }, _ => ran++);
118 late.Clock = () => Now.AddHours(1);
119 Assert.Equal(CKeySafeActionOutcomeKind.Rejected, (await late.HandleAsync(text)).Kind);
120 Assert.Equal(0, ran);
121 }
122
123 [Fact]
124 public async Task CrossDeviceNeedsPendingRequestInfoAndTheSdkPrompt()
125 {
126 int ran = 0;
127 var urls = new List<string>();
128 string status = "pending";
129 var a = Actions(url =>
130 {
131 urls.Add(url);
132 string id = url.Substring(url.LastIndexOf('/') + 1);
133 return "{\"request_id\":\"" + id + "\",\"device\":\"Chrome on Windows\",\"location\":\"near Lagos, Nigeria\",\"requested_at\":\"2026-11-07T20:14:03Z\",\"status\":\"" + status + "\"}";
134 })
135 .Trust("citeory.com", "2026-11", Key, "https://citeory.com/api/ckey/requests/{request_id}")
136 // Registered same-device; the envelope's cross-device scope wins.
137 .Register("signin.request", new[] { "citeory.com" }, _ => ran++);
138 var outcome = await a.HandleAsync(Payload("valid cross-device sign-in, no params"));
139 Assert.Equal(CKeySafeActionOutcomeKind.NeedsConfirmation, outcome.Kind);
140 Assert.Equal(new[] { "https://citeory.com/api/ckey/requests/sess-41fd0a9be2c74" }, urls);
141 Assert.Equal(0, ran);
142 var request = outcome.Request!;
143 Assert.Contains("citeory.com (verified)", request.Message);
144 Assert.Contains("near Lagos, Nigeria", request.Message);
145 Assert.True(await request.Decide(true));
146 Assert.Equal(1, ran);
147 Assert.False(await request.Decide(true));
148 Assert.Equal(1, ran);
149 }
150
151 [Fact]
152 public async Task CrossDeviceWithoutPendingRequestIsRejected()
153 {
154 int ran = 0;
155 var a = Actions(_ => "{\"request_id\":\"sess-41fd0a9be2c74\",\"status\":\"approved\"}")
156 .Trust("citeory.com", "2026-11", Key, "https://citeory.com/r/{request_id}")
157 .Register("signin.request", new[] { "citeory.com" }, _ => ran++);
158 Assert.Equal(CKeySafeActionOutcomeKind.Rejected, (await a.HandleAsync(Payload("valid cross-device sign-in, no params"))).Kind);
159 var b = Actions(_ => null)
160 .Trust("citeory.com", "2026-11", Key, "https://citeory.com/r/{request_id}")
161 .Register("open.library", new[] { "citeory.com" }, CKeySafeActionScope.CrossDevice, null, _ => { ran++; return Task.CompletedTask; });
162 Assert.Equal(CKeySafeActionOutcomeKind.Rejected, (await b.HandleAsync(Payload("valid same-device action"))).Kind);
163 Assert.Equal(0, ran);
164 }
165
166 [Fact]
167 public async Task DeclineRunsNothing()
168 {
169 int ran = 0;
170 var a = Actions(_ => "{\"request_id\":\"sess-41fd0a9be2c74\",\"status\":\"pending\"}")
171 .Trust("citeory.com", "2026-11", Key, "https://login.citeory.com/r/{request_id}")
172 .Register("signin.request", new[] { "citeory.com" }, _ => ran++);
173 var request = (await a.HandleAsync(Payload("valid cross-device sign-in, no params"))).Request!;
174 Assert.False(await request.Decide(false));
175 Assert.False(await request.Decide(true));
176 Assert.True(request.IsDecided);
177 Assert.Equal(0, ran);
178 }
179
180 [Fact]
181 public async Task KeyFiles()
182 {
183 var fetched = new List<string>();
184 var a = new CKeySafeActions("com.viadere", useKeyFiles: true) { Clock = () => Now };
185 a.Fetch = (url, _) =>
186 {
187 fetched.Add(url);
188 return Task.FromResult<string?>("{\"issuer\":\"citeory.com\",\"keys\":[{\"id\":\"2026-11\",\"alg\":\"Ed25519\",\"public_key\":\"" + Key + "\",\"not_after\":\"2027-11-01T00:00:00Z\"}]}");
189 };
190 int ran = 0;
191 a.Register("open.library", new[] { "citeory.com" }, _ => ran++);
192 Assert.Equal(CKeySafeActionOutcomeKind.Performed, (await a.HandleAsync(Payload("valid same-device action"))).Kind);
193 Assert.Equal(new[] { "https://citeory.com/.well-known/ckey-safe-actions.json" }, fetched);
194 Assert.Equal(1, ran);
195 }
196
197 [Fact]
198 public void RequestInfoUrlStaysOnTheIssuersDomain()
199 {
200 Assert.Equal("https://citeory.com/r/sess-1", CKeySafeActions.RequestInfoUrl("https://citeory.com/r/{request_id}", "citeory.com", "sess-1"));
201 Assert.Equal("https://api.citeory.com/r/x", CKeySafeActions.RequestInfoUrl("https://api.citeory.com/r/{request_id}", "citeory.com", "x"));
202 Assert.Null(CKeySafeActions.RequestInfoUrl("http://citeory.com/r/{request_id}", "citeory.com", "x"));
203 Assert.Null(CKeySafeActions.RequestInfoUrl("https://evilciteory.com/r/{request_id}", "citeory.com", "x"));
204 Assert.Null(CKeySafeActions.RequestInfoUrl("https://citeory.com.evil.example/r/{request_id}", "citeory.com", "x"));
205 Assert.Null(CKeySafeActions.RequestInfoUrl("https://citeory.com@evil.example/r/{request_id}", "citeory.com", "x"));
206 Assert.Null(CKeySafeActions.RequestInfoUrl(null, "citeory.com", "x"));
207 }
208
209 [Fact]
210 public void ArgumentChecks()
211 {
212 Assert.Throws<ArgumentException>(() => new CKeySafeActions("not an app id"));
213 Assert.Throws<ArgumentOutOfRangeException>(() => new CKeySafeActions("com.viadere", 3601));
214 Assert.Throws<ArgumentException>(() => new CKeySafeActions("com.viadere").Trust("Citeory.com", "k", Key));
215 Assert.Throws<ArgumentException>(() => new CKeySafeActions("com.viadere").Trust("citeory.com", "k", "abc"));
216 Assert.Throws<ArgumentException>(() => new CKeySafeActions("com.viadere").Register("Open", new[] { "citeory.com" }, _ => { }));
217 Assert.Throws<ArgumentException>(() => new CKeySafeActions("com.viadere").Register("open", Array.Empty<string>(), _ => { }));
218 }
219
220 [Fact]
221 public void MiniJsonReader()
222 {
223 var m = (Dictionary<string, object?>)MiniJson.Parse("{\"a\":[1,true,null,\"x\\u0041\\n\"],\"b\":{}}")!;
224 Assert.Equal(new object?[] { 1.0, true, null, "xA\n" }, (List<object?>)m["a"]!);
225 Assert.Null(MiniJson.Parse("{\"a\":1} trailing"));
226 Assert.Null(MiniJson.Parse(new string('[', 40) + new string(']', 40)));
227 Assert.Null(MiniJson.Parse("{\"a\":"));
228 }
229 }
230}