oydid 0.9.1 → 0.9.3

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9c74b5cd9e592efe1514c9110cff25a131ddc0a45727e035d48656bc0c20d2f8
4
- data.tar.gz: bd936eb03e018e15ae334907c7087629f48f17c2cc54bdeebb8a81542e2f2af9
3
+ metadata.gz: b941c83aec6492dfb37d272fd83a77f1b036902b70aab64634fed657d1ccaf6a
4
+ data.tar.gz: 0ba0f288c5c30e2cd7d7d831bd072ef0c4924082d70fba44634e1412578a8dc7
5
5
  SHA512:
6
- metadata.gz: 8fddcdf2ce2328ba9329263b1de2826cec3c3d4947c7cd5e599972798cffb10887e8bf2205d208961494a7296a818c90a4fea679f2b9b1c01109645c8fde59bf
7
- data.tar.gz: fdaab3ac52a14b4313db2d7d52058a41bdd6b72b2ca580441f5620a246d73d2fdf753a0530aef76d44520d8347573852aa6e3ae27d173371d54b09eeb86c874c
6
+ metadata.gz: ddc6020c44371fee58f2e527513e4d94974ee5d4a3ebabd22a01b5cc039f5c91cfe1a0ab0f4046c50a356ab0784db6a626c971c5ee18e10b076e03625250605e
7
+ data.tar.gz: eaba43282881c1239cde7af7733707d5beff7670b27388327926b4e7a5c739e9c221c1f9b70565a629adb3e7b4ee43dd3ebb3cb977bf6b2dd885ff6f55a34e93
data/VERSION CHANGED
@@ -1 +1 @@
1
- 0.9.1
1
+ 0.9.3
data/lib/oydid/basic.rb CHANGED
@@ -338,7 +338,7 @@ class Oydid
338
338
  return [nil, msg]
339
339
  exit
340
340
  end
341
- pubKey = did_document["doc"]["key"].split(":").first rescue nil
341
+ pubKey = did_document["doc"]["key"].split(":")[0] rescue nil
342
342
  if pubKey.nil?
343
343
  return [nil, "cannot resolve " + did.to_s]
344
344
  else
@@ -367,9 +367,9 @@ class Oydid
367
367
  # get current public key
368
368
  case key_type
369
369
  when "doc"
370
- keys = [did_document["doc"]["key"].split(":").first] rescue nil
370
+ keys = [did_document["doc"]["key"].split(":")[0]] rescue nil
371
371
  when "rev"
372
- keys = [did_document["doc"]["key"].split(":").last] rescue nil
372
+ keys = [did_document["doc"]["key"].split(":")[1]] rescue nil
373
373
  else
374
374
  return [nil, "invalid key type: " + key_type]
375
375
  end
@@ -1248,6 +1248,30 @@ class Oydid
1248
1248
  end
1249
1249
  end
1250
1250
 
1251
+ # Marks a failure of the queried repository itself (HTTP 5xx) as opposed to
1252
+ # a DID that is simply not stored there. Without the distinction a caller
1253
+ # turns a broken upstream into "not found", which claims the identifier
1254
+ # never existed - see upstream_error?.
1255
+ UPSTREAM_ERROR_PREFIX = "upstream error"
1256
+
1257
+ def self.upstream_error?(message)
1258
+ message.to_s.start_with?(UPSTREAM_ERROR_PREFIX)
1259
+ end
1260
+
1261
+ # Failure message for a non-200 response: the repository's own error field
1262
+ # when it sent one, otherwise a message naming status and URL. A 5xx gets
1263
+ # the prefix above so callers can tell it apart from a missing DID.
1264
+ def self.http_error_message(response, url)
1265
+ msg = (response.parsed_response["error"].to_s rescue "")
1266
+ return msg unless msg.to_s == ""
1267
+ code = response.code.to_i rescue 0
1268
+ if code >= 500
1269
+ UPSTREAM_ERROR_PREFIX + " " + code.to_s + " from " + url.to_s
1270
+ else
1271
+ "invalid response from " + url.to_s
1272
+ end
1273
+ end
1274
+
1251
1275
  def self.retrieve_document(doc_identifier, doc_file, doc_location, options)
1252
1276
  # in-process callers can supply the DID document directly (e.g. read from
1253
1277
  # a local database) to avoid any HTTP/file lookup
@@ -1271,11 +1295,7 @@ class Oydid
1271
1295
  end
1272
1296
  retVal = HTTParty.get(doc_location + "/doc/" + doc_identifier + option_str)
1273
1297
  if retVal.code != 200
1274
- msg = retVal.parsed_response["error"].to_s rescue ""
1275
- if msg.to_s == ""
1276
- msg = "invalid response from " + doc_location.to_s + "/doc/" + doc_identifier.to_s
1277
- end
1278
- return [nil, msg]
1298
+ return [nil, http_error_message(retVal, doc_location.to_s + "/doc/" + doc_identifier.to_s)]
1279
1299
  end
1280
1300
  if options.transform_keys(&:to_s)["trace"]
1281
1301
  if options[:silent].nil? || !options[:silent]
@@ -1324,8 +1344,7 @@ class Oydid
1324
1344
  doc_location = doc_location.sub("%3A%2F%2F","://").sub("%3A", ":")
1325
1345
  retVal = HTTParty.get(doc_location + "/doc_raw/" + doc_hash)
1326
1346
  if retVal.code != 200
1327
- msg = retVal.parsed_response("error").to_s rescue "invalid response from " + doc_location.to_s + "/doc/" + doc_hash.to_s
1328
- return [nil, msg]
1347
+ return [nil, http_error_message(retVal, doc_location.to_s + "/doc_raw/" + doc_hash.to_s)]
1329
1348
  end
1330
1349
  if options.transform_keys(&:to_s)["trace"]
1331
1350
  if options[:silent].nil? || !options[:silent]
data/lib/oydid/log.rb CHANGED
@@ -44,10 +44,7 @@ class Oydid
44
44
  log_location = log_location.gsub("%2F%2F","//")
45
45
  retVal = HTTParty.get(log_location + "/log/" + did_hash)
46
46
  if retVal.code != 200
47
- msg = retVal.parsed_response("error").to_s rescue
48
- "invalid response from " + log_location.to_s + "/log/" + did_hash.to_s
49
-
50
- return [nil, msg]
47
+ return [nil, http_error_message(retVal, log_location.to_s + "/log/" + did_hash.to_s)]
51
48
  end
52
49
  if options.transform_keys(&:to_s)["trace"]
53
50
  if options[:silent].nil? || !options[:silent]
@@ -79,9 +76,7 @@ class Oydid
79
76
  log_location = log_location.gsub("%2F%2F","//")
80
77
  retVal = HTTParty.get(log_location + "/log/" + log_hash + "/item")
81
78
  if retVal.code != 200
82
- msg = retVal.parsed_response("error").to_s rescue
83
- "invalid response from " + log_location.to_s + "/log/" + log_hash.to_s + "/item"
84
- return [nil, msg]
79
+ return [nil, http_error_message(retVal, log_location.to_s + "/log/" + log_hash.to_s + "/item")]
85
80
  end
86
81
  if options.transform_keys(&:to_s)["trace"]
87
82
  if options[:silent].nil? || !options[:silent]
@@ -361,7 +356,7 @@ class Oydid
361
356
  currentDID["verification"] += JSON.pretty_generate(doc) + "\n"
362
357
  currentDID["verification"] += "(Details: https://ownyourdata.github.io/oydid/#calculate_hash)" + "\n\n"
363
358
  end
364
- current_public_doc_key = currentDID["doc"]["key"].split(":").first rescue ""
359
+ current_public_doc_key = currentDID["doc"]["key"].split(":")[0] rescue ""
365
360
 
366
361
  when 0 # TERMINATE
367
362
  currentDID["termination_log_id"] = i
@@ -506,7 +501,7 @@ class Oydid
506
501
  return currentDID
507
502
  end
508
503
  next_doc = next_doc.first["doc"]
509
- if pubKeys.include?(next_doc["key"].split(":").first)
504
+ if pubKeys.include?(next_doc["key"].split(":")[0])
510
505
  currentDID["verification"] += "⚠️ no key rotation in updated DID Document" + "\n"
511
506
  end
512
507
  currentDID["verification"] += "\n"
@@ -558,9 +553,22 @@ class Oydid
558
553
  # handle DID Rotation
559
554
  if (i == (currentDID["log"].length-1))
560
555
  if options[:followAlsoKnownAs]
556
+ # the payload of a DID Document is arbitrary JSON: an
557
+ # Array or a scalar is valid and simply carries no
558
+ # alsoKnownAs, so only a Hash can be inspected for it.
559
+ # Without this guard a revoked DID whose document holds
560
+ # e.g. [5] raised NoMethodError here, the repository
561
+ # answered 500 with an empty body, and the resolver
562
+ # degraded that to "not found" instead of "deactivated".
561
563
  current_doc = currentDID["doc"]
562
- if current_doc["doc"].transform_keys(&:to_s).has_key?("alsoKnownAs")
563
- rotate_DID = current_doc["doc"].transform_keys(&:to_s)["alsoKnownAs"]
564
+ doc_payload = current_doc.is_a?(Hash) ? current_doc["doc"] : nil
565
+ doc_payload = doc_payload.transform_keys(&:to_s) if doc_payload.is_a?(Hash)
566
+ if doc_payload.is_a?(Hash) && doc_payload.has_key?("alsoKnownAs")
567
+ # DID Core defines alsoKnownAs as a set; older
568
+ # documents wrote a bare String
569
+ rotate_DID = doc_payload["alsoKnownAs"]
570
+ rotate_DID = rotate_DID.first if rotate_DID.is_a?(Array)
571
+ rotate_DID = rotate_DID.to_s
564
572
  if rotate_DID.start_with?("did:")
565
573
  rotate_DID_method = rotate_DID.split(":").take(2).join(":")
566
574
  did_orig = currentDID["did"]
@@ -586,8 +594,14 @@ class Oydid
586
594
  currentDID["verification"] += "(Details: https://ownyourdata.github.io/oydid/#did_rotation)" + "\n\n"
587
595
  end
588
596
  when "did:oyd"
589
- puts "try to resolve did:oyd with our own resolver"
590
- puts "add verification text"
597
+ # DID Rotation to another did:oyd identifier
598
+ # is not implemented yet: resolving it needs
599
+ # a second pass through this resolver.
600
+ if verification_output
601
+ currentDID["verification"] += "DID rotation to: " + rotate_DID.to_s + "\n"
602
+ currentDID["verification"] += "⚠️ rotation to another did:oyd identifier is not supported yet\n"
603
+ currentDID["verification"] += "(Details: https://ownyourdata.github.io/oydid/#did_rotation)" + "\n\n"
604
+ end
591
605
  else
592
606
  # do nothing: DID Rotation is not supported for this DID method yet
593
607
  end
data/lib/oydid.rb CHANGED
@@ -383,8 +383,8 @@ class Oydid
383
383
  old_privateKey = nil
384
384
  old_revocationKey = nil
385
385
  old_did_key = did_info["doc"]["key"].to_s
386
- old_publicDocKey = old_did_key.split(":").first.to_s
387
- old_publicRevKey = old_did_key.split(":").last.to_s
386
+ old_publicDocKey = old_did_key.split(":")[0].to_s
387
+ old_publicRevKey = old_did_key.split(":")[1].to_s
388
388
 
389
389
  verified, vmsg = verify_revocation_log(did_info, options[:log_revoke_old],
390
390
  "log_revoke_old", options)
@@ -460,6 +460,16 @@ class Oydid
460
460
  publicKey = public_key(privateKey, options).first
461
461
  pubRevoKey = public_key(revocationKey, options).first
462
462
  end
463
+ # The "key" field is the control field of a did:oyd: it names who may
464
+ # update the document (slot 0) and who may revoke it (slot 1) - nothing
465
+ # else. Keys that are content rather than control (key agreement above
466
+ # all) belong in the payload as verification methods, where they are
467
+ # covered by the identifier hash just the same.
468
+ #
469
+ # The field is positional and has exactly two slots. Readers must use an
470
+ # explicit index; .last would silently return the wrong key if a third
471
+ # slot were ever appended, and the mistake would only surface at
472
+ # revocation time. A spec enforces this across the repository.
463
473
  did_key = publicKey + ":" + pubRevoKey
464
474
 
465
475
  # the document is assembled once, in the first phase of a CMSM flow -
@@ -512,6 +522,10 @@ class Oydid
512
522
  end
513
523
 
514
524
  if revocationKey.to_s == ""
525
+ if options[:cmsm]
526
+ err = cmsm_verify_signature(subDidHash, cmsm_sig_rev, pubRevoKey, "key-rev")
527
+ return [nil, nil, nil, err] if !err.nil?
528
+ end
515
529
  signedSubDidHash = cmsm_sig_rev
516
530
  else
517
531
  signedSubDidHash = sign(subDidHash, revocationKey, LOG_HASH_OPTIONS).first
@@ -534,6 +548,8 @@ class Oydid
534
548
  cmsm_sig_rev, cmsm_sig_doc, cmsm_sig_create,
535
549
  did_old, cmsm_log_revoke_old), options)
536
550
  end
551
+ err = cmsm_verify_signature(l2_doc, cmsm_sig_doc, publicKey, "key-doc")
552
+ return [nil, nil, nil, err] if !err.nil?
537
553
  l2_sig = cmsm_sig_doc
538
554
  else
539
555
  l2_sig = sign(l2_doc, privateKey, options).first
@@ -595,6 +611,8 @@ class Oydid
595
611
  cmsm_sig_rev, cmsm_sig_doc, cmsm_sig_create,
596
612
  did_old, cmsm_log_revoke_old), options)
597
613
  end
614
+ err = cmsm_verify_signature(l1_doc, cmsm_sig_create, old_publicDocKey, "key-doc-old")
615
+ return [nil, nil, nil, err] if !err.nil?
598
616
  l1_sig = cmsm_sig_create
599
617
  else
600
618
  l1_sig = sign(l1_doc, old_privateKey, options).first
@@ -628,6 +646,8 @@ class Oydid
628
646
  cmsm_sig_rev, cmsm_sig_doc, cmsm_sig_create,
629
647
  did_old, cmsm_log_revoke_old), options)
630
648
  end
649
+ err = cmsm_verify_signature(l1_doc, cmsm_sig_create, publicKey, "key-doc")
650
+ return [nil, nil, nil, err] if !err.nil?
631
651
  l1_sig = cmsm_sig_create
632
652
  else
633
653
  l1_sig = sign(l1_doc, privateKey, options).first
@@ -736,7 +756,7 @@ class Oydid
736
756
  end
737
757
 
738
758
  did_key = did_info["doc"]["key"].to_s
739
- pubRevKey = did_key.split(":").last.to_s
759
+ pubRevKey = did_key.split(":")[1].to_s
740
760
  subDid = {"doc": did_info["doc"]["doc"], "key": did_key}.to_json
741
761
  subDidHash = multi_hash(canonical(subDid), LOG_HASH_OPTIONS).first
742
762
 
@@ -776,6 +796,23 @@ class Oydid
776
796
  # state of a CMSM flow that has to survive between phases. Deliberately only
777
797
  # inputs and collected signatures - no derived hashes: every phase recomputes
778
798
  # r1 / l2_doc / the DID from these values, so there is nothing to keep in sync.
799
+ # A signature collected in a CMSM phase is only worth something if it was
800
+ # really made with the key the challenge named. Without this check the flow
801
+ # takes anything: the log entries are built from whatever the client sent and
802
+ # only fail on resolution - by which point the DID exists and has claimed the
803
+ # public key, which lets anyone permanently burn a key they do not hold.
804
+ #
805
+ # The message starts with "CMSM " so that the REST layers report it as a
806
+ # client error (400), not as a server fault.
807
+ def self.cmsm_verify_signature(value, signature, public_key, with)
808
+ if public_key.to_s == ""
809
+ return "CMSM cannot verify the " + with.to_s + " signature: no public key in session"
810
+ end
811
+ success, _msg = (verify(value, signature, public_key) rescue [false, ""])
812
+ return nil if success == true
813
+ "CMSM signature for " + with.to_s + " is invalid"
814
+ end
815
+
779
816
  def self.cmsm_state(publicKey, pubRevoKey, revocationKey, did_doc, ts, doc_location, sig_rev, sig_doc, sig_create, did_old = nil, log_revoke_old = nil)
780
817
  {
781
818
  publicKey: publicKey,
data/spec/oydid_spec.rb CHANGED
@@ -519,6 +519,65 @@ describe "OYDID handling" do
519
519
  # end
520
520
  # end
521
521
 
522
+ # regression: the payload of a DID Document is arbitrary JSON, not
523
+ # necessarily an object. A revoked DID whose payload is an Array used to
524
+ # raise NoMethodError in the alsoKnownAs lookup - the repository then
525
+ # answered 500 with an empty body and the resolver reported "not found"
526
+ # instead of "deactivated".
527
+ describe "dag_update with a non-object DID Document payload" do
528
+ def revoked_state(payload)
529
+ { "did" => "did:oyd:zQmPyjnkL52gQxBBhPuCFkf167MzpCKquqbu5Qt5ia2JGTr",
530
+ "doc" => { "doc" => payload, "key" => "", "log" => "" },
531
+ "log" => [{ "ts" => 1647732475, "op" => 1, "doc" => "zQmcVLwEtzU8By7TTeRFGvVUKm7HhZS6GgRTDFt51QNnans",
532
+ "sig" => "", "previous" => [] }] }
533
+ end
534
+
535
+ it "does not raise for an Array payload" do
536
+ expect {
537
+ Oydid.dag_update(revoked_state([5]), { followAlsoKnownAs: true })
538
+ }.not_to raise_error
539
+ end
540
+
541
+ it "does not raise for a scalar payload" do
542
+ expect {
543
+ Oydid.dag_update(revoked_state("plain"), { followAlsoKnownAs: true })
544
+ }.not_to raise_error
545
+ end
546
+
547
+ it "accepts alsoKnownAs written as a set" do
548
+ state = revoked_state({ "alsoKnownAs" => ["did:oyd:zQmaBZTghndXTgxNwfbdpVLWdFf6faYE4oeuN2zzXdQt1kh"] })
549
+ expect {
550
+ Oydid.dag_update(state, { followAlsoKnownAs: true })
551
+ }.not_to raise_error
552
+ end
553
+ end
554
+
555
+ # a broken repository (5xx) has to stay distinguishable from a DID that is
556
+ # not stored there - otherwise the caller reports "not found" and thereby
557
+ # claims the identifier never existed
558
+ describe "failure messages for non-200 responses" do
559
+ it "marks a 5xx as an upstream error" do
560
+ reply = double(code: 500, parsed_response: "")
561
+ msg = Oydid.http_error_message(reply, "https://oydid.ownyourdata.eu/doc/zQmXY")
562
+ expect(Oydid.upstream_error?(msg)).to be true
563
+ expect(msg).to include("500")
564
+ end
565
+
566
+ it "passes the repository's own error through" do
567
+ reply = double(code: 410, parsed_response: { "error" => "revoked" })
568
+ msg = Oydid.http_error_message(reply, "https://oydid.ownyourdata.eu/doc/zQmXY")
569
+ expect(msg).to eq "revoked"
570
+ expect(Oydid.upstream_error?(msg)).to be false
571
+ end
572
+
573
+ it "does not mark a 4xx without an error field as upstream error" do
574
+ reply = double(code: 404, parsed_response: nil)
575
+ msg = Oydid.http_error_message(reply, "https://oydid.ownyourdata.eu/doc/zQmXY")
576
+ expect(msg).to start_with("invalid response from")
577
+ expect(Oydid.upstream_error?(msg)).to be false
578
+ end
579
+ end
580
+
522
581
  # main functionds
523
582
  Dir.glob(File.expand_path("../input/main/*_read.doc", __FILE__)).each do |input|
524
583
  it "execute read for #{input.split('/').last}" do
@@ -807,6 +866,51 @@ describe "OYDID handling" do
807
866
  # Which identifier a resolved DID document announces as its own. An update
808
867
  # mints a new did:oyd, but the identifier a relying party holds is the one it
809
868
  # asked for - that is the one the document has to carry.
869
+ # The "key" field is positional: "<public document key>:<public revocation key>".
870
+ # Reading it with .first/.last works only as long as it has exactly two slots -
871
+ # the day a third one is added (e.g. a key agreement key), .last silently
872
+ # returns the wrong key and the error only surfaces at revocation time.
873
+ # Every read must therefore use an explicit index.
874
+ describe "positional access to the key field" do
875
+ repo_root = File.expand_path("../../..", __FILE__)
876
+ sources = %w[
877
+ ruby-gem/lib
878
+ cli
879
+ repository/app
880
+ uni-registrar-driver-did-oyd/app
881
+ ].map { |d| File.join(repo_root, d) }.select { |d| File.directory?(d) }
882
+
883
+ ruby_files = sources.flat_map { |d| Dir.glob(File.join(d, "**", "*.rb")) }
884
+
885
+ it "finds sources to scan" do
886
+ skip "running outside the repository checkout" if ruby_files.empty?
887
+ expect(ruby_files).not_to be_empty
888
+ end
889
+
890
+ it "never reads a split key field with .last" do
891
+ skip "running outside the repository checkout" if ruby_files.empty?
892
+ offenders = ruby_files.flat_map do |file|
893
+ File.readlines(file).each_with_index.filter_map do |line, i|
894
+ "#{file.sub(repo_root + "/", "")}:#{i + 1}" if line =~ /split\((["'])\:\1\)\.last/
895
+ end
896
+ end
897
+ expect(offenders).to eq []
898
+ end
899
+
900
+ it "never reads a split key field with .first" do
901
+ skip "running outside the repository checkout" if ruby_files.empty?
902
+ offenders = ruby_files.flat_map do |file|
903
+ File.readlines(file).each_with_index.filter_map do |line, i|
904
+ next unless line =~ /split\((["'])\:\1\)\.first/
905
+ # the DID identifier itself is also colon-separated and legitimately
906
+ # read with .first - only the key field is at issue here
907
+ "#{file.sub(repo_root + "/", "")}:#{i + 1}" if line.include?('"key"') || line.include?("'key'") || line =~ /_key(s)?\.split/
908
+ end
909
+ end
910
+ expect(offenders).to eq []
911
+ end
912
+ end
913
+
810
914
  describe "document_id" do
811
915
  let(:first_did) { "zQmSE1hzumtZ7AoK1qhHf4t5kiKsujMsJSHqoXtWrdd7K7W" }
812
916
  let(:second_did) { "zQmfEb3KgYZjZUPLTHPmFPdcV6peF5itB5NmJ9N6gaxxE8K" }
@@ -858,4 +962,103 @@ describe "OYDID handling" do
858
962
  end
859
963
  end
860
964
 
965
+
966
+ describe "CMSM signature verification" do
967
+ # An in-memory stand-in for the session store the repository provides, so
968
+ # these examples run without a database and without the network.
969
+ class MemoryCmsmStore
970
+ def initialize; @data = {}; end
971
+ def set(session, payload); @data[session] = payload; end
972
+ def get(session); @data[session]; end
973
+ def delete(session); @data.delete(session); end
974
+ end
975
+
976
+ let(:store) { MemoryCmsmStore.new }
977
+ let(:priv) { Oydid.generate_private_key("", "ed25519-priv", {}).first }
978
+ let(:pub) { Oydid.public_key(priv, {}).first }
979
+
980
+ def cmsm_options(extra = {})
981
+ { cmsm: true, key_type: "ed25519", cmsm_store: store,
982
+ return_secrets: true, skip_publish: true }.merge(extra)
983
+ end
984
+
985
+ # phase 1: hand over the public key, receive session and value to sign
986
+ def start_flow
987
+ status, msg = Oydid.create({ "key" => pub }, cmsm_options)
988
+ expect(msg).to eq("cmsm")
989
+ status.transform_keys(&:to_s)
990
+ end
991
+
992
+ describe "cmsm_verify_signature" do
993
+ it "accepts a signature made with the named key" do
994
+ signature = Oydid.sign("hello", priv, {}).first
995
+ expect(Oydid.cmsm_verify_signature("hello", signature, pub, "key-doc")).to be_nil
996
+ end
997
+
998
+ it "rejects a signature over a different value" do
999
+ signature = Oydid.sign("hello", priv, {}).first
1000
+ msg = Oydid.cmsm_verify_signature("goodbye", signature, pub, "key-doc")
1001
+ expect(msg).to eq("CMSM signature for key-doc is invalid")
1002
+ end
1003
+
1004
+ it "rejects a signature made with a different key" do
1005
+ other = Oydid.generate_private_key("", "ed25519-priv", {}).first
1006
+ signature = Oydid.sign("hello", other, {}).first
1007
+ expect(Oydid.cmsm_verify_signature("hello", signature, pub, "key-doc")).not_to be_nil
1008
+ end
1009
+
1010
+ it "rejects nonsense instead of raising" do
1011
+ expect(Oydid.cmsm_verify_signature("hello", "not-a-signature", pub, "key-doc")).not_to be_nil
1012
+ end
1013
+
1014
+ it "refuses to verify without a public key" do
1015
+ signature = Oydid.sign("hello", priv, {}).first
1016
+ msg = Oydid.cmsm_verify_signature("hello", signature, "", "key-doc")
1017
+ expect(msg).to include("no public key in session")
1018
+ end
1019
+
1020
+ # the message is what the REST layers match on to answer 400 instead of 500
1021
+ it "phrases failures as client errors" do
1022
+ msg = Oydid.cmsm_verify_signature("hello", "zBogus", pub, "key-rev")
1023
+ expect(msg).to start_with("CMSM ")
1024
+ end
1025
+ end
1026
+
1027
+ describe "a running flow" do
1028
+ it "advances when the signature is correct" do
1029
+ phase1 = start_flow
1030
+ signature = Oydid.sign(phase1["sign"], priv, {}).first
1031
+
1032
+ status, msg = Oydid.create({}, cmsm_options(cmsm_session: phase1["session"], sig: signature))
1033
+
1034
+ expect(msg).to eq("cmsm")
1035
+ expect(status.transform_keys(&:to_s)["session"]).to eq(phase1["session"])
1036
+ end
1037
+
1038
+ # Before this check the flow took any signature, built the log entries from
1039
+ # it and answered 200. The DID did not resolve, but it had claimed the
1040
+ # public key - so anyone could burn a key they did not hold.
1041
+ it "refuses a signature the client cannot have made" do
1042
+ phase1 = start_flow
1043
+ forged = Oydid.sign(phase1["sign"],
1044
+ Oydid.generate_private_key("", "ed25519-priv", {}).first, {}).first
1045
+
1046
+ status, msg = Oydid.create({}, cmsm_options(cmsm_session: phase1["session"], sig: forged))
1047
+
1048
+ expect(status).to be_nil
1049
+ expect(msg).to eq("CMSM signature for key-doc is invalid")
1050
+ end
1051
+
1052
+ it "refuses a signature over a value from another flow" do
1053
+ phase1 = start_flow
1054
+ other = start_flow
1055
+ signature = Oydid.sign(other["sign"], priv, {}).first
1056
+
1057
+ status, msg = Oydid.create({}, cmsm_options(cmsm_session: phase1["session"], sig: signature))
1058
+
1059
+ expect(status).to be_nil
1060
+ expect(msg).to start_with("CMSM ")
1061
+ end
1062
+ end
1063
+ end
861
1064
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: oydid
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.9.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Christoph Fabianek
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2026-08-26 00:00:00.000000000 Z
10
+ date: 2026-08-29 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: simple_dag