1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
//! Intent-driven algorithm selection.
//!
//! [`Query`] answers "which algorithms match these predicates?".
//! This module answers the question an agent actually has: *"I need to do X
//! under constraints Y — what should I call, and what will bite me?"*
//!
//! The output is a [`Recommendation`] carrying the chosen algorithm, the
//! reasoning, the rejected alternatives with *why* they were rejected, and the
//! constraints the caller must honour. An agent can act on it; a human can
//! audit it.
//!
//! ```
//! use ic_ontology::select::{recommend, Intent, Policy};
//!
//! let r = recommend(Intent::EncryptMessage, Policy::FIPS_APPROVED).unwrap();
//! assert_eq!(r.primary.id, "aes-256-gcm");
//!
//! let r = recommend(Intent::EncryptMessage, Policy::DEFAULT).unwrap();
//! assert_eq!(r.primary.id, "chacha20-poly1305");
//! ```
use crate::query::Query;
use crate::types::{Class, Constraint, Entry, ImplStatus, Purpose};
/// What the caller is trying to accomplish.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Intent {
/// Encrypt a message so it cannot be read or altered.
EncryptMessage,
/// Detect modification of data, with no key involved.
HashData,
/// Authenticate a message with a shared key.
AuthenticateMessage,
/// Turn a shared secret or master key into application keys.
DeriveKey,
/// Turn a user password into a key, or store it for later verification.
HashPassword,
/// Establish a shared secret with a remote peer.
AgreeKey,
/// Sign data so a third party can verify authorship.
SignData,
/// Generate unpredictable bytes.
GenerateRandom,
}
impl Intent {
/// Stable identifier used in CLI and MCP output.
pub const fn id(self) -> &'static str {
match self {
Self::EncryptMessage => "encrypt-message",
Self::HashData => "hash-data",
Self::AuthenticateMessage => "authenticate-message",
Self::DeriveKey => "derive-key",
Self::HashPassword => "hash-password",
Self::AgreeKey => "agree-key",
Self::SignData => "sign-data",
Self::GenerateRandom => "generate-random",
}
}
/// Every intent, for enumeration.
pub const ALL: &'static [Intent] = &[
Intent::EncryptMessage,
Intent::HashData,
Intent::AuthenticateMessage,
Intent::DeriveKey,
Intent::HashPassword,
Intent::AgreeKey,
Intent::SignData,
Intent::GenerateRandom,
];
/// Parse an intent from its identifier.
pub fn from_id(id: &str) -> Option<Intent> {
Self::ALL.iter().copied().find(|i| i.id() == id)
}
/// The class and purpose this intent maps onto.
///
/// `AgreeKey` deliberately leaves the class open: a Diffie-Hellman scheme
/// and a KEM are different objects that serve the same goal, and a
/// post-quantum migration needs the KEM to surface for this intent.
const fn shape(self) -> (Option<Class>, Purpose) {
match self {
Self::EncryptMessage => (Some(Class::Aead), Purpose::Confidentiality),
Self::HashData => (Some(Class::Hash), Purpose::Integrity),
Self::AuthenticateMessage => (Some(Class::Mac), Purpose::Authentication),
Self::DeriveKey => (Some(Class::Kdf), Purpose::KeyDerivation),
Self::HashPassword => (Some(Class::PasswordKdf), Purpose::PasswordHashing),
Self::AgreeKey => (None, Purpose::KeyEstablishment),
Self::SignData => (Some(Class::Signature), Purpose::NonRepudiation),
Self::GenerateRandom => (Some(Class::Drbg), Purpose::RandomGeneration),
}
}
}
/// Deployment constraints that shape the choice.
///
/// `PartialEq` is derived because a policy is plain configuration and callers
/// compare them — to tell whether a detected policy differs from a constant, or
/// to assert one in a test. Deriving it also means adding a field cannot
/// quietly leave an equality check comparing only the old ones.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Policy {
/// Only algorithms usable in the FIPS approved mode are acceptable.
pub require_fips: bool,
/// Minimum classical security strength in bits.
pub min_classical_bits: u16,
/// Minimum quantum security strength in bits; above zero this excludes
/// every classical asymmetric scheme.
pub min_quantum_bits: u16,
/// The target has AES hardware acceleration.
///
/// The portable backend has none, so absent a hardware backend this makes
/// ChaCha20-Poly1305 the faster choice by a wide margin.
pub aes_hardware: bool,
}
impl Policy {
/// Sensible defaults: 128-bit strength, no FIPS requirement, no AES
/// hardware assumed.
pub const DEFAULT: Policy = Policy {
require_fips: false,
min_classical_bits: 128,
min_quantum_bits: 0,
aes_hardware: false,
};
/// Everything must be usable in the FIPS approved mode of operation.
pub const FIPS_APPROVED: Policy = Policy {
require_fips: true,
min_classical_bits: 128,
min_quantum_bits: 0,
aes_hardware: false,
};
/// The default policy, with `aes_hardware` filled in from the CPU.
///
/// Prefer this over [`Policy::DEFAULT`] in a running program: on a machine
/// with AES-NI it flips the authenticated-encryption recommendation from
/// ChaCha20-Poly1305 to AES-256-GCM, which is the faster answer there.
pub fn detected() -> Policy {
Policy {
aes_hardware: crate::runtime::backend().fast_bulk_symmetric(),
..Policy::DEFAULT
}
}
/// A FIPS policy with `aes_hardware` filled in from the CPU.
pub fn detected_fips() -> Policy {
Policy {
aes_hardware: crate::runtime::backend().fast_bulk_symmetric(),
..Policy::FIPS_APPROVED
}
}
/// A 192-bit floor with a FIPS requirement, as CNSA-aligned profiles ask
/// for.
///
/// This is what moves the answer from P-256 to P-384: the curves differ
/// only in strength, so the selector picks on the number rather than on a
/// hard-coded preference.
pub const CNSA: Policy = Policy {
require_fips: true,
min_classical_bits: 192,
min_quantum_bits: 0,
aes_hardware: false,
};
/// Resist a future quantum adversary.
pub const POST_QUANTUM: Policy = Policy {
require_fips: false,
min_classical_bits: 128,
min_quantum_bits: 128,
aes_hardware: false,
};
}
impl Default for Policy {
fn default() -> Self {
Self::DEFAULT
}
}
/// An algorithm that was considered and passed over.
#[derive(Debug, Clone, Copy)]
pub struct Rejected {
/// The algorithm's identifier.
pub id: &'static str,
/// Why it was not chosen.
pub reason: &'static str,
}
/// The result of a selection.
#[derive(Debug, Clone, Copy)]
pub struct Recommendation {
/// The intent this answers.
pub intent: Intent,
/// The recommended algorithm.
pub primary: &'static Entry,
/// Why this one.
pub rationale: &'static str,
/// A second choice, when one exists.
pub alternative: Option<&'static Entry>,
/// Algorithms considered and passed over, with reasons.
pub rejected: [Option<Rejected>; 3],
/// The constraints the caller must honour to use `primary` safely.
pub must_observe: &'static [Constraint],
}
impl Recommendation {
/// Iterate the rejected candidates.
pub fn rejected(&self) -> impl Iterator<Item = &Rejected> {
self.rejected.iter().flatten()
}
}
/// Why no algorithm could satisfy the request.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NoRecommendation {
/// The registry knows a suitable algorithm, but it is not implemented here.
KnownButUnavailable {
/// The algorithm the caller should obtain elsewhere.
id: &'static str,
},
/// Nothing in the registry satisfies the policy at all.
NothingSatisfiesPolicy,
}
/// Choose an algorithm for `intent` under `policy`.
///
/// Returns [`NoRecommendation::KnownButUnavailable`] rather than silently
/// downgrading when the correct answer exists but this build does not provide
/// it — the failure mode that would otherwise push an agent into using an
/// unapproved algorithm to satisfy a FIPS requirement.
pub fn recommend(intent: Intent, policy: Policy) -> Result<Recommendation, NoRecommendation> {
let (class, purpose) = intent.shape();
let base = Query::new().purpose(purpose);
let base = match class {
Some(c) => base.class(c),
None => base,
};
let base = base
.min_classical_bits(policy.min_classical_bits)
.min_quantum_bits(policy.min_quantum_bits);
let base = if policy.require_fips {
base.fips_approved_only()
} else {
base
};
// Is there a suitable algorithm at all, implemented or not?
let any_suitable = base.run().next();
let available = base.available_only().run().next();
if available.is_none() {
return match any_suitable {
Some(e) => Err(NoRecommendation::KnownButUnavailable { id: e.id }),
None => Err(NoRecommendation::NothingSatisfiesPolicy),
};
}
Ok(build(intent, policy, base))
}
/// Rank the available candidates for an intent.
fn build(intent: Intent, policy: Policy, base: Query) -> Recommendation {
let pick = |id: &str| crate::query::get(id).filter(|e| base.matches(e));
let available = |id: &str| pick(id).filter(|e| e.status == ImplStatus::Available);
let (primary, rationale, alternative, rejected) = match intent {
Intent::EncryptMessage => {
// Without AES hardware the portable AES backend is far slower than
// ChaCha20, so the ranking flips on `aes_hardware` — but a FIPS
// policy removes ChaCha20 from consideration entirely.
let chacha = available("chacha20-poly1305");
let aes = available("aes-256-gcm");
match (policy.require_fips, policy.aes_hardware, chacha, aes) {
(false, false, Some(c), a) => (
c,
"ChaCha20-Poly1305 is authenticated, needs no hardware support, and on a CPU \
without AES instructions is far faster than the portable constant-time AES.",
a,
[
Some(Rejected {
id: "aes-256-gcm",
reason: "Approved and equally secure, but this CPU has no AES \
instructions, so the portable backend is far slower.",
}),
Some(Rejected {
id: "aes-cbc",
reason: "Unauthenticated; needs a separate MAC and invites padding \
oracles.",
}),
None,
],
),
(_, _, c, Some(a)) => (
a,
"AES-256-GCM is the approved authenticated cipher and retains 128-bit strength \
against a quantum adversary.",
c,
[
Some(Rejected {
id: "chacha20-poly1305",
reason: if policy.require_fips {
"Not approved for the FIPS approved mode of operation."
} else {
"Faster in software, but AES hardware makes AES-GCM the better \
choice."
},
}),
Some(Rejected {
id: "aes-ctr",
reason: "Unauthenticated; it is the confidentiality half of GCM.",
}),
None,
],
),
(_, _, Some(c), None) => (c, "The only available authenticated cipher.", None, [None, None, None]),
_ => unreachable!("recommend() checked that a candidate exists"),
}
}
Intent::HashData => {
let sha256 = available("sha2-256");
let sha512 = available("sha2-512");
let sha3 = available("sha3-256");
match (sha256, sha512) {
(Some(a), b) => (
a,
"SHA-256 is approved, universally interoperable, and fast on every target.",
b,
[
Some(Rejected {
id: "sha3-256",
reason: "Equally sound; choose it only when algorithm diversity from \
SHA-2 is a requirement.",
}),
Some(Rejected {
id: "sha-1",
reason: "Chosen-prefix collisions are practical; disallowed.",
}),
None,
],
),
(None, Some(b)) => (
b,
"SHA-512 meets the requested strength.",
sha3,
[None, None, None],
),
_ => fallback(base),
}
}
Intent::AuthenticateMessage => match available("hmac-sha2-256") {
Some(a) => (
a,
"HMAC-SHA-256 is approved, fast, and the interoperable default.",
available("hmac-sha2-512"),
[
Some(Rejected {
id: "cmac-aes-256",
reason:
"Approved, but slow here and only preferable with AES hardware and \
no hash accelerator.",
}),
Some(Rejected {
id: "poly1305",
reason: "One-time keys only; use the ChaCha20-Poly1305 AEAD instead.",
}),
None,
],
),
None => fallback(base),
},
Intent::DeriveKey => match available("hkdf-sha2-256") {
Some(a) => (
a,
"HKDF extracts then expands, so it is correct for non-uniform input such as a \
Diffie-Hellman shared secret.",
available("sp800-108-counter-hmac-sha2-256"),
[
Some(Rejected {
id: "sp800-108-counter-hmac-sha2-256",
reason:
"Preferable when the input is already a uniform key-derivation key.",
}),
Some(Rejected {
id: "pbkdf2-hmac-sha2-256",
reason: "For passwords only; needlessly slow for key material.",
}),
None,
],
),
None => fallback(base),
},
Intent::HashPassword => {
let argon2 = available("argon2id");
let pbkdf2 = available("pbkdf2-hmac-sha2-256");
match (argon2, pbkdf2) {
// Outside FIPS, memory-hardness is the whole point: PBKDF2 is
// attacked far faster on a GPU than it is defended on a CPU.
(Some(a), p) => (
a,
"Argon2id is memory-hard, so an attacker must spend RAM as well as time. Its \
first half-pass indexes data-independently and the rest data-dependently, \
which is why RFC 9106 recommends it over the other two variants.",
p,
[
Some(Rejected {
id: "pbkdf2-hmac-sha2-256",
reason: "Approved, but not memory-hard: choose it only when FIPS \
approval is a requirement.",
}),
Some(Rejected {
id: "sha2-256",
reason: "A bare hash is far too fast to protect a password.",
}),
None,
],
),
(None, Some(p)) => (
p,
"PBKDF2 is the only approved password-based KDF. Use at least 600000 \
iterations and a fresh 128-bit salt; it is not memory-hard, so the iteration \
count is the only lever you have.",
None,
[
Some(Rejected {
id: "argon2id",
reason: "Memory-hard and stronger, but not approved for the FIPS \
approved mode of operation.",
}),
Some(Rejected {
id: "sha2-256",
reason: "A bare hash is far too fast to protect a password.",
}),
None,
],
),
_ => fallback(base),
}
}
Intent::AgreeKey => {
let x25519 = available("x25519");
let p256 = available("ecdh-p256");
match (x25519, p256) {
// Outside a FIPS policy, X25519 is the safer default: no point
// validation to get wrong, and no invalid-curve attack surface.
(Some(x), p) => (
x,
"X25519 is fast and hard to misuse. Run the shared secret through HKDF \
together with both public keys before using it.",
p,
[
Some(Rejected {
id: "ecdh-p256",
reason: "Approved and implemented here, but it needs peer-key \
validation that X25519 does not.",
}),
Some(Rejected {
id: "ml-kem-768",
reason: "Post-quantum and implemented, but larger and slower. \
Pass --post-quantum to choose it, and prefer a hybrid \
with the classical scheme over either alone.",
}),
None,
],
),
(None, Some(p)) => (
p,
"ECDH P-256 is the approved key agreement scheme. Peer public keys are \
validated against the curve equation, and the shared secret must go through \
a KDF before use.",
None,
[
Some(Rejected {
id: "x25519",
reason: "Not approved for the FIPS approved mode of operation.",
}),
Some(Rejected {
id: "ml-kem-768",
reason: "Post-quantum and implemented, but larger and slower. \
Pass --post-quantum to choose it, and prefer a hybrid \
with the classical scheme over either alone.",
}),
None,
],
),
// A post-quantum policy leaves neither classical scheme, and
// there are three ML-KEM parameter sets to choose between.
(None, None) => match available("ml-kem-768") {
Some(m) => (
m,
"ML-KEM-768 is FIPS 203's middle parameter set, security category 3, and \
the one most protocols standardise on. Deploy it in a hybrid with X25519 \
or a NIST curve, and derive the session key from both secrets.",
available("ml-kem-1024"),
[
Some(Rejected {
id: "ml-kem-512",
reason: "Implemented and ACVP-checked, with the smallest keys and \
ciphertexts, but at category 1 it has the thinnest \
margin against lattice cryptanalysis, which is young. \
Choose it deliberately where size dominates.",
}),
Some(Rejected {
id: "ml-kem-1024",
reason: "Category 5, for a policy that requires it, at roughly \
a third more size and time than ML-KEM-768.",
}),
None,
],
),
None => fallback(base),
},
}
}
Intent::SignData => {
let ed = available("ed25519");
let ecdsa = available("ecdsa-p256-sha256");
match (ed, ecdsa) {
(Some(e), other) => (
e,
"Ed25519 signs deterministically, so there is no nonce to leak or repeat, and \
it has no point-validation step to get wrong.",
other,
[
Some(Rejected {
id: "ecdsa-p256-sha256",
reason: "Approved and implemented here; choose it when you need FIPS \
approval or interoperability with X.509 and TLS.",
}),
Some(Rejected {
id: "ml-dsa-65",
reason: "Post-quantum and implemented, but larger and slower. \
Pass --post-quantum to choose it, and prefer a hybrid \
with the classical scheme over either alone.",
}),
Some(Rejected {
id: "rsa-pss-sha256",
reason: "Approved and implemented here, but a 2048-bit modulus buys \
112-bit strength for signatures an order of magnitude \
larger and slower. Choose it only to meet an existing \
interface.",
}),
],
),
(None, Some(p)) => (
p,
"ECDSA P-256 is the approved signature scheme. This implementation derives \
its nonce per RFC 6979, so the usual ECDSA nonce-reuse failure cannot occur.",
None,
[
Some(Rejected {
id: "ed25519",
reason: "Not approved for the FIPS approved mode of operation.",
}),
Some(Rejected {
id: "ml-dsa-65",
reason: "Post-quantum and implemented, but larger and slower. \
Pass --post-quantum to choose it, and prefer a hybrid \
with the classical scheme over either alone.",
}),
Some(Rejected {
id: "rsa-pss-sha256",
reason: "Also approved, but slower and far larger at the same \
strength. Choose it only to meet an existing \
interface.",
}),
],
),
// A post-quantum policy leaves neither classical scheme, and
// there are three ML-DSA parameter sets to choose between.
(None, None) => match available("ml-dsa-65") {
Some(m) => (
m,
"ML-DSA-65 is FIPS 204's middle parameter set, security category 3. Sign \
with a classical scheme alongside it and require both signatures to \
check.",
available("ml-dsa-87"),
[
Some(Rejected {
id: "ml-dsa-44",
reason: "Implemented and ACVP-checked, with the smallest keys and \
signatures, but at category 2 it has the thinnest \
margin against lattice cryptanalysis, which is young. \
Choose it deliberately where size dominates.",
}),
Some(Rejected {
id: "ml-dsa-87",
reason: "Category 5, for a policy that requires it, with \
signatures about two fifths larger than ML-DSA-65's.",
}),
None,
],
),
None => fallback(base),
},
}
}
Intent::GenerateRandom => match available("hmac-drbg-sha2-256") {
Some(a) => (
a,
"HMAC_DRBG conditions its own entropy input. Reach for it through ic_drbg::Rng, \
which seeds from the OS and reseeds on schedule.",
available("ctr-drbg-aes-256"),
[
Some(Rejected {
id: "ctr-drbg-aes-256",
reason: "Requires exactly 48 bytes of already-uniform entropy.",
}),
None,
None,
],
),
None => fallback(base),
},
};
Recommendation {
intent,
primary,
rationale,
alternative,
rejected,
must_observe: primary.constraints,
}
}
/// Fall back to the first matching entry when the curated ranking has nothing
/// to say — for example under an unusual strength floor.
fn fallback(
base: Query,
) -> (
&'static Entry,
&'static str,
Option<&'static Entry>,
[Option<Rejected>; 3],
) {
let mut it = base.available_only().run();
let primary = it
.next()
.expect("recommend() checked that a candidate exists");
let alternative = it.next();
// "The only" was true while every family had one member; with three
// ML-KEM parameter sets it would have told a caller there was no choice
// when there was.
let rationale = if alternative.is_some() {
"The first of several available algorithms matching the requested class and strength; \
no curated ranking covers this policy, so compare the alternative before choosing."
} else {
"The only available algorithm matching the requested class and strength."
};
(primary, rationale, alternative, [None, None, None])
}
#[cfg(test)]
mod tests {
use super::*;
/// The detected policies differ from their constants in exactly one field.
///
/// Both are built with struct update syntax, which silently picks up any
/// field added to `Policy` later — that is the point — but it also means a
/// typo swapping the base constant would go unnoticed. `detected_fips`
/// building on `DEFAULT` instead of `FIPS_APPROVED` would drop the FIPS
/// requirement while still looking correct.
#[test]
fn the_detected_policies_only_override_the_hardware_flag() {
let hw = crate::runtime::backend().fast_bulk_symmetric();
let d = Policy::detected();
assert_eq!(d.aes_hardware, hw, "detected() must read the CPU");
assert_eq!(
Policy {
aes_hardware: Policy::DEFAULT.aes_hardware,
..d
},
Policy::DEFAULT,
"detected() must otherwise be DEFAULT"
);
let f = Policy::detected_fips();
assert_eq!(f.aes_hardware, hw, "detected_fips() must read the CPU");
assert!(f.require_fips, "detected_fips() must actually require FIPS");
assert_eq!(
Policy {
aes_hardware: Policy::FIPS_APPROVED.aes_hardware,
..f
},
Policy::FIPS_APPROVED,
"detected_fips() must otherwise be FIPS_APPROVED"
);
}
#[test]
fn fips_policy_selects_aes_gcm_over_chacha() {
let r = recommend(Intent::EncryptMessage, Policy::FIPS_APPROVED).unwrap();
assert_eq!(r.primary.id, "aes-256-gcm");
assert!(r
.rejected()
.any(|x| x.id == "chacha20-poly1305" && x.reason.contains("approved mode")));
}
#[test]
fn without_aes_hardware_chacha_wins() {
let r = recommend(Intent::EncryptMessage, Policy::DEFAULT).unwrap();
assert_eq!(r.primary.id, "chacha20-poly1305");
assert_eq!(r.alternative.map(|e| e.id), Some("aes-256-gcm"));
}
#[test]
fn with_aes_hardware_the_ranking_flips() {
let policy = Policy {
aes_hardware: true,
..Policy::DEFAULT
};
let r = recommend(Intent::EncryptMessage, policy).unwrap();
assert_eq!(r.primary.id, "aes-256-gcm");
}
#[test]
fn recommendations_carry_the_constraints_that_matter() {
let r = recommend(Intent::EncryptMessage, Policy::FIPS_APPROVED).unwrap();
assert!(r
.must_observe
.iter()
.any(|c| c.id == "unique-nonce-per-key"));
}
#[test]
fn fips_policy_never_substitutes_an_unapproved_scheme() {
// Ed25519 and X25519 are available but unapproved; the approved
// alternatives must be chosen, never the convenient ones.
let r = recommend(Intent::SignData, Policy::FIPS_APPROVED).unwrap();
assert_eq!(r.primary.id, "ecdsa-p256-sha256");
assert!(r
.rejected()
.any(|x| x.id == "ed25519" && x.reason.contains("approved mode")));
let r = recommend(Intent::AgreeKey, Policy::FIPS_APPROVED).unwrap();
assert_eq!(r.primary.id, "ecdh-p256");
assert!(r
.rejected()
.any(|x| x.id == "x25519" && x.reason.contains("approved mode")));
}
/// Outside a FIPS policy the Curve25519 options stay the default, with the
/// approved alternatives offered rather than hidden.
#[test]
fn default_policy_prefers_curve25519_but_offers_the_approved_option() {
let r = recommend(Intent::SignData, Policy::DEFAULT).unwrap();
assert_eq!(r.primary.id, "ed25519");
assert_eq!(r.alternative.map(|e| e.id), Some("ecdsa-p256-sha256"));
let r = recommend(Intent::AgreeKey, Policy::DEFAULT).unwrap();
assert_eq!(r.primary.id, "x25519");
assert_eq!(r.alternative.map(|e| e.id), Some("ecdh-p256"));
}
/// A post-quantum policy now gets an answer rather than an apology.
///
/// These two assertions used to be the opposite: both intents returned
/// `KnownButUnavailable`, because ML-KEM-768 and ML-DSA-65 were registered
/// `experimental` and `recommend` will not hand back something it cannot
/// vouch for. They are checked against NIST's published ACVP vectors now --
/// 50 cases for the KEM, 55 for the signature scheme -- so the status is
/// `Available` and the answer exists.
#[test]
fn a_post_quantum_policy_now_has_answers() {
let r = recommend(Intent::AgreeKey, Policy::POST_QUANTUM).unwrap();
assert_eq!(r.primary.id, "ml-kem-768");
assert!(r.primary.strength.quantum >= 128);
let r = recommend(Intent::SignData, Policy::POST_QUANTUM).unwrap();
assert_eq!(r.primary.id, "ml-dsa-65");
assert!(r.primary.strength.quantum >= 128);
// The hybrid advice has to survive becoming available. It is the one
// piece of guidance a caller most needs here and the easiest to lose
// while editing an entry's status.
let r = recommend(Intent::AgreeKey, Policy::POST_QUANTUM).unwrap();
assert!(
r.must_observe
.iter()
.any(|c| c.id == "deploy-post-quantum-in-a-hybrid"),
"ml-kem-768 no longer tells the caller to deploy it in a hybrid"
);
}
/// Declining is still what happens when nothing fits.
///
/// This is the property the two tests above used to carry: `recommend`
/// would rather say it has nothing than substitute something weaker. It
/// used to be reachable through `KnownButUnavailable`, which no longer
/// occurs anywhere in the policy space -- everything the registry knows and
/// that satisfies a policy is implemented. `NothingSatisfiesPolicy` is
/// where the behaviour lives now, and it is reached often.
#[test]
fn a_policy_nothing_satisfies_is_declined_not_approximated() {
// No hash offers quantum resistance at this level, and the answer must
// be a refusal rather than the strongest thing available.
let err = recommend(
Intent::HashData,
Policy {
require_fips: false,
min_classical_bits: 0,
min_quantum_bits: 512,
aes_hardware: false,
},
)
.unwrap_err();
assert_eq!(err, NoRecommendation::NothingSatisfiesPolicy);
// And a classical strength nothing here reaches.
let err = recommend(
Intent::SignData,
Policy {
require_fips: true,
min_classical_bits: 512,
min_quantum_bits: 0,
aes_hardware: false,
},
)
.unwrap_err();
assert_eq!(err, NoRecommendation::NothingSatisfiesPolicy);
}
#[test]
fn symmetric_intents_survive_a_post_quantum_policy() {
let r = recommend(Intent::EncryptMessage, Policy::POST_QUANTUM).unwrap();
assert_eq!(r.primary.strength.quantum, 128);
}
#[test]
fn every_intent_resolves_under_the_default_policy() {
for intent in Intent::ALL {
let outcome = recommend(*intent, Policy::DEFAULT);
assert!(
outcome.is_ok(),
"{} produced no recommendation: {:?}",
intent.id(),
outcome.err()
);
}
}
#[test]
fn recommended_algorithms_are_always_actually_available() {
for intent in Intent::ALL {
for policy in [Policy::DEFAULT, Policy::FIPS_APPROVED, Policy::POST_QUANTUM] {
if let Ok(r) = recommend(*intent, policy) {
assert_eq!(r.primary.status, ImplStatus::Available, "{}", intent.id());
assert!(!r.primary.rust_path.is_empty());
if policy.require_fips {
assert!(
r.primary.fips.permitted_in_approved_mode(),
"{} recommended an unapproved algorithm under a FIPS policy",
intent.id()
);
}
}
}
}
}
/// A recommendation must satisfy every field of the policy that asked for
/// it, across the whole policy space rather than three named points.
///
/// The test above this one walks `DEFAULT`, `FIPS_APPROVED` and
/// `POST_QUANTUM`, and of the four fields in a `Policy` it compares one:
/// `require_fips`. The two strength minimums are what an agent sets when it
/// has a requirement it cannot express any other way, and nothing checked
/// that the answer honoured them. Being handed a classical scheme after
/// asking for quantum resistance is the worst shape of wrong answer here --
/// silent, and in the direction of weakness.
///
/// Declining is always acceptable: this library would rather say it has
/// nothing than substitute something weaker, and
/// `fips_policy_never_substitutes_an_unapproved_scheme` covers that
/// separately. What is checked here is that an answer, when there is one,
/// is not weaker than what was asked for.
#[test]
fn no_recommendation_is_weaker_than_the_policy_that_asked_for_it() {
let mut answered = 0;
let mut declined = 0;
for intent in Intent::ALL {
for require_fips in [false, true] {
for min_classical_bits in [0u16, 112, 128, 192, 256] {
for min_quantum_bits in [0u16, 128, 192] {
for aes_hardware in [false, true] {
let policy = Policy {
require_fips,
min_classical_bits,
min_quantum_bits,
aes_hardware,
};
let Ok(r) = recommend(*intent, policy) else {
declined += 1;
continue;
};
answered += 1;
let what = r.primary.id;
let strength = r.primary.strength;
assert!(
r.primary.status == ImplStatus::Available,
"{} recommended {what}, which is not available",
intent.id()
);
assert!(
!require_fips || r.primary.fips.permitted_in_approved_mode(),
"{} recommended {what} under a FIPS policy",
intent.id()
);
assert!(
strength.classical >= min_classical_bits,
"{} asked for {min_classical_bits} classical bits and got \
{what}, which has {}",
intent.id(),
strength.classical
);
assert!(
strength.quantum >= min_quantum_bits,
"{} asked for {min_quantum_bits} quantum bits and got \
{what}, which has {}",
intent.id(),
strength.quantum
);
// The alternative is offered to be chosen, so it is
// held to the same policy as the primary. One that
// does not satisfy it is a trap: the reason to
// mention it is that the caller might take it.
if let Some(alt) = r.alternative {
assert!(
alt.status == ImplStatus::Available,
"{} offered {} as an alternative and it is not available",
intent.id(),
alt.id
);
assert!(
!require_fips || alt.fips.permitted_in_approved_mode(),
"{} offered {} under a FIPS policy",
intent.id(),
alt.id
);
assert!(
alt.strength.classical >= min_classical_bits
&& alt.strength.quantum >= min_quantum_bits,
"{} offered {} as an alternative, which is weaker than \
the policy allows",
intent.id(),
alt.id
);
}
}
}
}
}
}
// Floors. Every combination declining would satisfy every assertion
// above, and so would a loop that never ran.
let total = answered + declined;
assert_eq!(
total,
Intent::ALL.len() * 2 * 5 * 3 * 2,
"the policy space was not walked"
);
assert!(
answered > total / 2,
"only {answered} of {total} policies produced an answer, which is too \
few for this to have tested much"
);
assert!(
declined > 0,
"nothing was declined anywhere in the space, so the strict policies \
are not as strict as they look"
);
}
#[test]
fn intent_ids_roundtrip() {
for i in Intent::ALL {
assert_eq!(Intent::from_id(i.id()), Some(*i));
}
}
/// Outside FIPS the memory-hard option wins; under FIPS the approved one
/// does, and each names the other as the road not taken.
#[test]
fn password_hashing_tracks_the_policy() {
let r = recommend(Intent::HashPassword, Policy::DEFAULT).unwrap();
assert_eq!(r.primary.id, "argon2id");
assert_eq!(r.alternative.map(|e| e.id), Some("pbkdf2-hmac-sha2-256"));
assert!(r
.rejected()
.any(|x| x.id == "pbkdf2-hmac-sha2-256" && x.reason.contains("memory-hard")));
let r = recommend(Intent::HashPassword, Policy::FIPS_APPROVED).unwrap();
assert_eq!(r.primary.id, "pbkdf2-hmac-sha2-256");
assert!(r
.rejected()
.any(|x| x.id == "argon2id" && x.reason.contains("approved mode")));
}
}