@naughtbot/e2ee-payloads 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.test.ts CHANGED
@@ -27,6 +27,12 @@ import type {
27
27
  MailboxGpgDecryptResponseSuccessV1,
28
28
  MailboxCaptchaRequestPayloadV1,
29
29
  MailboxCaptchaResponsePayloadV1,
30
+ MailboxKeyInventoryApprovalBindingV1,
31
+ MailboxKeyInventoryEntryV1,
32
+ MailboxKeyInventoryRequestPayloadV1,
33
+ MailboxKeyInventoryResponsePayloadV1,
34
+ MailboxKeyInventoryResponseRejectedV1,
35
+ MailboxKeyInventoryResponseSharedV1,
30
36
  MailboxLinkApprovalPayloadV1,
31
37
  MailboxLinkRejectionPayloadV1,
32
38
  MailboxLinkRequestPayloadV1,
@@ -37,6 +43,8 @@ import type {
37
43
  MailboxSshSignResponsePayloadV1,
38
44
  MailboxSshSignResponseSuccessV1,
39
45
  NaughtBotApprovalBindingV1,
46
+ PublicKeyAlgorithm,
47
+ PublicKeyFormat,
40
48
  } from "./index.ts";
41
49
 
42
50
  const captchaApprovalBindingProfile =
@@ -815,3 +823,286 @@ describe("KeyPurpose", () => {
815
823
  assert.ok(!(["ssh", "gpg", "age", "pkcs11"] as string[]).includes(bad));
816
824
  });
817
825
  });
826
+
827
+ interface KeyInventoryVectorsFixture {
828
+ canonical_owner: string;
829
+ approval_binding_format: string;
830
+ empty_key_list_digest: string;
831
+ key_list_digest: string;
832
+ canonical_keys_json: string;
833
+ canonical_binding_json: string;
834
+ approval_binding_sha256_hex: string;
835
+ binding: MailboxKeyInventoryApprovalBindingV1;
836
+ keys: MailboxKeyInventoryEntryV1[];
837
+ }
838
+
839
+ function loadKeyInventoryVectors(): KeyInventoryVectorsFixture {
840
+ return JSON.parse(
841
+ readFileSync(
842
+ new URL("../../fixtures/key-inventory-v1-vectors.json", import.meta.url),
843
+ "utf8",
844
+ ),
845
+ ) as KeyInventoryVectorsFixture;
846
+ }
847
+
848
+ describe("MailboxKeyInventoryRequestPayloadV1", () => {
849
+ it("round-trips a purpose-filtered request including pkcs11", () => {
850
+ const request: MailboxKeyInventoryRequestPayloadV1 = {
851
+ purposes: ["ssh", "gpg", "age", "pkcs11"],
852
+ source_info: { hostname: "work-laptop" },
853
+ };
854
+ const json = JSON.stringify(request);
855
+ assert.ok(json.includes('"purposes":["ssh","gpg","age","pkcs11"]'));
856
+
857
+ const parsed = JSON.parse(json) as MailboxKeyInventoryRequestPayloadV1;
858
+ assert.equal(parsed.purposes.length, 4);
859
+ assert.equal(parsed.purposes[3], "pkcs11");
860
+ });
861
+ });
862
+
863
+ // Regression coverage for the closed canonical key-format enums. The
864
+ // PublicKeyAlgorithm / PublicKeyFormat unions are closed sets; an unknown
865
+ // member (e.g. "rsa" / "spki_der") must be a `tsc` error rather than a
866
+ // silent accept. Casting through `unknown` documents the intentionally bad
867
+ // runtime value.
868
+ describe("canonical key-format enums", () => {
869
+ it("PublicKeyAlgorithm is a closed set", () => {
870
+ const known: PublicKeyAlgorithm[] = ["ecdsa_p256", "ed25519", "x25519"];
871
+ for (const algorithm of known) {
872
+ assert.ok(["ecdsa_p256", "ed25519", "x25519"].includes(algorithm));
873
+ }
874
+ const bad = "rsa" as unknown as PublicKeyAlgorithm;
875
+ assert.ok(!(known as string[]).includes(bad));
876
+ });
877
+
878
+ it("PublicKeyFormat is a closed set", () => {
879
+ const known: PublicKeyFormat[] = ["sec1_compressed", "raw_32"];
880
+ for (const format of known) {
881
+ assert.ok(["sec1_compressed", "raw_32"].includes(format));
882
+ }
883
+ const bad = "spki_der" as unknown as PublicKeyFormat;
884
+ assert.ok(!(known as string[]).includes(bad));
885
+ });
886
+
887
+ it("rejects public_key_hex inconsistent with algorithm + format", () => {
888
+ // The schema carries the relaxed `^(02|03)?[0-9a-f]{64}$` pattern;
889
+ // receivers MUST additionally enforce the per-algorithm layout. This
890
+ // mirrors the Go validateCanonicalKeyMaterial helper.
891
+ const ok: MailboxKeyInventoryEntryV1 = {
892
+ purpose: "ssh",
893
+ id: "ssh-key-0001",
894
+ device_key_id: "se-handle-ssh-0001",
895
+ algorithm: "ecdsa_p256",
896
+ public_key_format: "sec1_compressed",
897
+ public_key_hex:
898
+ "02a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2",
899
+ };
900
+ assert.ok(canonicalKeyMaterialValid(ok));
901
+
902
+ // ecdsa_p256 with a 64-hex (raw_32-length) value is inconsistent.
903
+ assert.ok(
904
+ !canonicalKeyMaterialValid({
905
+ ...ok,
906
+ public_key_hex:
907
+ "b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3",
908
+ }),
909
+ );
910
+ // ed25519 with a 66-hex (sec1-length) value is inconsistent.
911
+ assert.ok(
912
+ !canonicalKeyMaterialValid({
913
+ ...ok,
914
+ algorithm: "ed25519",
915
+ public_key_format: "raw_32",
916
+ }),
917
+ );
918
+ // algorithm/format pairing mismatch.
919
+ assert.ok(
920
+ !canonicalKeyMaterialValid({ ...ok, public_key_format: "raw_32" }),
921
+ );
922
+ });
923
+ });
924
+
925
+ function canonicalKeyMaterialValid(entry: MailboxKeyInventoryEntryV1): boolean {
926
+ const hex = entry.public_key_hex;
927
+ switch (entry.algorithm) {
928
+ case "ecdsa_p256":
929
+ return (
930
+ entry.public_key_format === "sec1_compressed" &&
931
+ hex.length === 66 &&
932
+ (hex.startsWith("02") || hex.startsWith("03"))
933
+ );
934
+ case "ed25519":
935
+ case "x25519":
936
+ return entry.public_key_format === "raw_32" && hex.length === 64;
937
+ default:
938
+ return false;
939
+ }
940
+ }
941
+
942
+ describe("MailboxKeyInventoryResponsePayloadV1", () => {
943
+ it("discriminates shared vs rejected via status", () => {
944
+ const fixture = loadKeyInventoryVectors();
945
+
946
+ const shared: MailboxKeyInventoryResponseSharedV1 = {
947
+ status: "shared",
948
+ request_envelope_id: "11111111-2222-4333-8444-555555555555",
949
+ keys: fixture.keys,
950
+ approval_binding: fixture.binding,
951
+ approval_binding_bytes: Buffer.from(
952
+ fixture.canonical_binding_json,
953
+ "utf8",
954
+ ).toString("base64"),
955
+ approval_binding_format: "key-inventory-approval-binding/v1+json",
956
+ approval_proof: approvalProofFixture(),
957
+ };
958
+ const sharedResp = JSON.parse(
959
+ JSON.stringify(shared),
960
+ ) as MailboxKeyInventoryResponsePayloadV1;
961
+ assert.equal(sharedResp.status, "shared");
962
+ if (sharedResp.status === "shared") {
963
+ assert.equal(sharedResp.keys.length, 4);
964
+ assert.equal(
965
+ Buffer.from(sharedResp.approval_binding_bytes, "base64").toString(
966
+ "utf8",
967
+ ),
968
+ fixture.canonical_binding_json,
969
+ );
970
+ // Integrity is the attested-key-zk proof alone (NaughtBot/workspace#22):
971
+ // no signing-key identity, no raw signature travels on the wire.
972
+ assert.equal(
973
+ sharedResp.approval_proof.version,
974
+ "approval-attested-key-proof/v1",
975
+ );
976
+ }
977
+ // Regression: the shared response wire JSON carries no device
978
+ // signing-key identity (`*_jkt`) and no raw approval signature.
979
+ const sharedWire = JSON.stringify(shared);
980
+ assert.ok(!sharedWire.includes("approving_device_signing_key_jkt"));
981
+ assert.ok(!sharedWire.includes("approval_signature"));
982
+ assert.ok(!sharedWire.includes("_jkt"));
983
+
984
+ const rejected: MailboxKeyInventoryResponseRejectedV1 = {
985
+ status: "rejected",
986
+ request_envelope_id: "11111111-2222-4333-8444-555555555555",
987
+ error_code: 1,
988
+ error_message: "User rejected the key inventory request",
989
+ };
990
+ const rejectedResp = JSON.parse(
991
+ JSON.stringify(rejected),
992
+ ) as MailboxKeyInventoryResponsePayloadV1;
993
+ assert.equal(rejectedResp.status, "rejected");
994
+ if (rejectedResp.status === "rejected") {
995
+ assert.equal(rejectedResp.error_code, 1);
996
+ }
997
+ });
998
+ });
999
+
1000
+ describe("MailboxKeyInventoryEntryV1 protocol metadata", () => {
1001
+ it("round-trips SSH / GPG / age / PKCS#11 export metadata", () => {
1002
+ const fixture = loadKeyInventoryVectors();
1003
+ const [ssh, gpg, age, pkcs11] = fixture.keys;
1004
+
1005
+ const sshParsed = JSON.parse(
1006
+ JSON.stringify(ssh),
1007
+ ) as MailboxKeyInventoryEntryV1;
1008
+ assert.equal(sshParsed.purpose, "ssh");
1009
+ assert.equal(
1010
+ sshParsed.ssh?.ssh_key_type,
1011
+ "sk-ecdsa-sha2-nistp256@openssh.com",
1012
+ );
1013
+ assert.ok(sshParsed.ssh?.ssh_fingerprint?.startsWith("SHA256:"));
1014
+ assert.equal(sshParsed.ssh?.ssh_sk_flags, 5);
1015
+
1016
+ const gpgParsed = JSON.parse(
1017
+ JSON.stringify(gpg),
1018
+ ) as MailboxKeyInventoryEntryV1;
1019
+ assert.equal(
1020
+ gpgParsed.gpg?.fingerprint_hex,
1021
+ "A1D597197F5C1DACB3E3BB7862BF2FC536D562FF",
1022
+ );
1023
+ assert.ok(gpgParsed.gpg?.armored_public_key?.includes("PGP PUBLIC KEY"));
1024
+ assert.equal(
1025
+ gpgParsed.gpg?.encryption_public_key_hex,
1026
+ "03b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2c3",
1027
+ );
1028
+
1029
+ const ageParsed = JSON.parse(
1030
+ JSON.stringify(age),
1031
+ ) as MailboxKeyInventoryEntryV1;
1032
+ assert.ok(ageParsed.age?.age_recipient?.startsWith("age1nb1"));
1033
+
1034
+ const pkcs11Parsed = JSON.parse(
1035
+ JSON.stringify(pkcs11),
1036
+ ) as MailboxKeyInventoryEntryV1;
1037
+ assert.equal(pkcs11Parsed.pkcs11?.cka_key_type, "CKK_EC");
1038
+ assert.equal(pkcs11Parsed.pkcs11?.can_sign, true);
1039
+ assert.equal(pkcs11Parsed.pkcs11?.can_derive, false);
1040
+ });
1041
+ });
1042
+
1043
+ describe("MailboxKeyInventoryApprovalBindingV1", () => {
1044
+ it("agrees with Go and Swift on the canonical key-inventory vectors", () => {
1045
+ const fixture = loadKeyInventoryVectors();
1046
+ assert.equal(
1047
+ fixture.canonical_owner,
1048
+ "NaughtBot/e2ee-payloads fixtures/key-inventory-v1-vectors.json",
1049
+ );
1050
+ assert.equal(fixture.keys.length, 4);
1051
+
1052
+ // SHA-256 over the canonical keys JSON MUST equal key_list_digest.
1053
+ const digest =
1054
+ "sha256:" +
1055
+ createHash("sha256")
1056
+ .update(fixture.canonical_keys_json, "utf8")
1057
+ .digest("hex");
1058
+ assert.equal(digest, fixture.key_list_digest);
1059
+ assert.equal(fixture.binding.key_list_digest, fixture.key_list_digest);
1060
+
1061
+ // The empty key list digest is SHA-256 of the JSON array "[]".
1062
+ const emptyDigest =
1063
+ "sha256:" + createHash("sha256").update("[]", "utf8").digest("hex");
1064
+ assert.equal(emptyDigest, fixture.empty_key_list_digest);
1065
+
1066
+ // Every fixture entry's canonical key material is internally consistent.
1067
+ for (const entry of fixture.keys) {
1068
+ assert.ok(
1069
+ canonicalKeyMaterialValid(entry),
1070
+ `fixture key ${entry.purpose} failed canonical validation`,
1071
+ );
1072
+ }
1073
+
1074
+ // Re-stringifying the fixture binding object (whose properties are
1075
+ // already in lexicographic order) reproduces the canonical binding bytes.
1076
+ // These bytes are the attested-key-zk proof input, not a signature input
1077
+ // (NaughtBot/workspace#22).
1078
+ assert.equal(
1079
+ JSON.stringify(fixture.binding),
1080
+ fixture.canonical_binding_json,
1081
+ );
1082
+
1083
+ // The proof statement signs `SHA256(approval_binding bytes)`, exactly as
1084
+ // the captcha flow does: SHA-256 over the canonical binding JSON MUST
1085
+ // equal approval_binding_sha256_hex.
1086
+ const bindingHash = createHash("sha256")
1087
+ .update(fixture.canonical_binding_json, "utf8")
1088
+ .digest("hex");
1089
+ assert.equal(bindingHash, fixture.approval_binding_sha256_hex);
1090
+
1091
+ // Regression for NaughtBot/workspace#22: no device signing-key identity
1092
+ // (`*_jkt`, raw signature) may appear in the binding wire bytes.
1093
+ assert.ok(!fixture.canonical_binding_json.includes("jkt"));
1094
+ assert.ok(!fixture.canonical_binding_json.includes("signature"));
1095
+
1096
+ const bindingParsed = JSON.parse(
1097
+ JSON.stringify(fixture.binding),
1098
+ ) as MailboxKeyInventoryApprovalBindingV1;
1099
+ assert.equal(bindingParsed.version, "key-inventory-approval-binding/v1");
1100
+ assert.equal(bindingParsed.request_envelope_type, "key_inventory_request");
1101
+ assert.deepEqual(bindingParsed.requested_purposes, [
1102
+ "ssh",
1103
+ "gpg",
1104
+ "age",
1105
+ "pkcs11",
1106
+ ]);
1107
+ });
1108
+ });
package/src/index.ts CHANGED
@@ -59,6 +59,11 @@ export type MailboxPkcs11DeriveResponsePayloadV1 = components["schemas"]["Mailbo
59
59
  export type MailboxPkcs11DeriveResponseSuccessV1 = components["schemas"]["MailboxPkcs11DeriveResponseSuccessV1"];
60
60
  export type MailboxPkcs11DeriveResponseFailureV1 = components["schemas"]["MailboxPkcs11DeriveResponseFailureV1"];
61
61
  export type KeyPurpose = components["schemas"]["KeyPurpose"];
62
+ export type PublicKeyAlgorithm = components["schemas"]["PublicKeyAlgorithm"];
63
+ export type PublicKeyFormat = components["schemas"]["PublicKeyFormat"];
64
+ export type Sec1CompressedPublicKeyHex = components["schemas"]["Sec1CompressedPublicKeyHex"];
65
+ export type Raw32PublicKeyHex = components["schemas"]["Raw32PublicKeyHex"];
66
+ export type CanonicalPublicKeyHex = components["schemas"]["CanonicalPublicKeyHex"];
62
67
  export type MailboxEnrollRequestPayloadV1 = components["schemas"]["MailboxEnrollRequestPayloadV1"];
63
68
  export type MailboxEnrollResponsePayloadV1 = components["schemas"]["MailboxEnrollResponsePayloadV1"];
64
69
  export type MailboxEnrollResponseApprovedV1 = components["schemas"]["MailboxEnrollResponseApprovedV1"];
@@ -86,3 +91,14 @@ export type MailboxFirstPartyPrivilegedActionRequestV1 = components["schemas"]["
86
91
  export type MailboxFirstPartyRequestPayloadV1 = components["schemas"]["MailboxFirstPartyRequestPayloadV1"];
87
92
  export type MailboxFirstPartyPrivilegedActionDecisionBindingV1 = components["schemas"]["MailboxFirstPartyPrivilegedActionDecisionBindingV1"];
88
93
  export type MailboxFirstPartyResponsePayloadV1 = components["schemas"]["MailboxFirstPartyResponsePayloadV1"];
94
+ export type MailboxKeyInventoryRequestPayloadV1 = components["schemas"]["MailboxKeyInventoryRequestPayloadV1"];
95
+ export type MailboxKeyInventoryApprovalBindingFormat = components["schemas"]["MailboxKeyInventoryApprovalBindingFormat"];
96
+ export type MailboxKeyInventoryEntryV1 = components["schemas"]["MailboxKeyInventoryEntryV1"];
97
+ export type KeyInventorySshMetadataV1 = components["schemas"]["KeyInventorySshMetadataV1"];
98
+ export type KeyInventoryGpgMetadataV1 = components["schemas"]["KeyInventoryGpgMetadataV1"];
99
+ export type KeyInventoryAgeMetadataV1 = components["schemas"]["KeyInventoryAgeMetadataV1"];
100
+ export type KeyInventoryPkcs11MetadataV1 = components["schemas"]["KeyInventoryPkcs11MetadataV1"];
101
+ export type MailboxKeyInventoryResponseSharedV1 = components["schemas"]["MailboxKeyInventoryResponseSharedV1"];
102
+ export type MailboxKeyInventoryResponseRejectedV1 = components["schemas"]["MailboxKeyInventoryResponseRejectedV1"];
103
+ export type MailboxKeyInventoryResponsePayloadV1 = components["schemas"]["MailboxKeyInventoryResponsePayloadV1"];
104
+ export type MailboxKeyInventoryApprovalBindingV1 = components["schemas"]["MailboxKeyInventoryApprovalBindingV1"];